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/public:/public \
static/email-templates \ static/email-templates \
frontend/dist/favicon.png:/frontend/favicon.png \ frontend/dist/favicon.png:/frontend/favicon.png \
frontend/dist/frontend:/frontend frontend/dist/frontend:/frontend \
i18n:/i18n
# Install dependencies for building. # Install dependencies for building.
.PHONY: deps .PHONY: deps

View File

@ -14,12 +14,14 @@ import (
) )
type configScript struct { type configScript struct {
RootURL string `json:"rootURL"` RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"` FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"` Messengers []string `json:"messengers"`
MediaProvider string `json:"mediaProvider"` MediaProvider string `json:"mediaProvider"`
NeedsRestart bool `json:"needsRestart"` NeedsRestart bool `json:"needsRestart"`
Update *AppUpdate `json:"update"` Update *AppUpdate `json:"update"`
Langs []i18nLang `json:"langs"`
Lang json.RawMessage `json:"lang"`
} }
// handleGetConfigScript returns general configuration as a Javascript // 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. // Sort messenger names with `email` always as the first item.
var names []string var names []string
for name := range app.messengers { for name := range app.messengers {
@ -51,13 +64,19 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update out.Update = app.update
app.Unlock() app.Unlock()
var ( // Write the Javascript variable opening;
b = bytes.Buffer{} b := bytes.Buffer{}
j = json.NewEncoder(&b)
)
b.Write([]byte(`var CONFIG = `)) 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. // 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 { if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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}) 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 { if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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}) 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 { 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) app.log.Printf("error fetching campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 { if len(out.Results) == 0 {
out.Results = []models.Campaign{} out.Results = []models.Campaign{}
@ -131,7 +133,8 @@ func handleGetCampaigns(c echo.Context) error {
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil { if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
app.log.Printf("error fetching campaign stats: %v", err) app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { if single {
@ -157,18 +160,20 @@ func handlePreviewCampaign(c echo.Context) error {
) )
if id < 1 { 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) err := app.queries.GetCampaignForPreview.Get(camp, id)
if err != nil { if err != nil {
if err == sql.ErrNoRows { 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) app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 var sub models.Subscriber
@ -180,7 +185,8 @@ func handlePreviewCampaign(c echo.Context) error {
} else { } else {
app.log.Printf("error fetching subscriber: %v", err) app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err) app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error compiling template: %v", err)) app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
} }
// Render the message body. // Render the message body.
@ -200,7 +206,7 @@ func handlePreviewCampaign(c echo.Context) error {
if err := m.Render(); err != nil { if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err) app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, 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())) return c.HTML(http.StatusOK, string(m.Body()))
@ -237,7 +243,8 @@ func handleCreateCampaign(c echo.Context) error {
uu, err := uuid.NewV4() uu, err := uuid.NewV4()
if err != nil { if err != nil {
app.log.Printf("error generating UUID: %v", err) 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. // Insert and read ID.
@ -257,13 +264,13 @@ func handleCreateCampaign(c echo.Context) error {
o.ListIDs, o.ListIDs,
); err != nil { ); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
"There aren't any subscribers in the target lists to create the campaign.")
} }
app.log.Printf("error creating campaign: %v", err) app.log.Printf("error creating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Hand over to the GET handler to return the last insertion.
@ -281,23 +288,25 @@ func handleUpdateCampaign(c echo.Context) error {
) )
if id < 1 { 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 var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows { 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) app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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) { if isCampaignalMutable(cm.Status) {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
"Cannot update a running or a finished campaign.")
} }
// Incoming params. // Incoming params.
@ -327,7 +336,8 @@ func handleUpdateCampaign(c echo.Context) error {
if err != nil { if err != nil {
app.log.Printf("error updating campaign: %v", err) app.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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) return handleGetCampaigns(c)
@ -341,18 +351,23 @@ func handleUpdateCampaignStatus(c echo.Context) error {
) )
if id < 1 { 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 var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows { 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) app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Incoming params.
@ -365,27 +380,27 @@ func handleUpdateCampaignStatus(c echo.Context) error {
switch o.Status { switch o.Status {
case models.CampaignStatusDraft: case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled { if cm.Status != models.CampaignStatusScheduled {
errMsg = "Only scheduled campaigns can be saved as drafts" errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
} }
case models.CampaignStatusScheduled: case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft { if cm.Status != models.CampaignStatusDraft {
errMsg = "Only draft campaigns can be scheduled" errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
} }
if !cm.SendAt.Valid { if !cm.SendAt.Valid {
errMsg = "Campaign needs a `send_at` date to be scheduled" errMsg = app.i18n.T("campaigns.needsSendAt")
} }
case models.CampaignStatusRunning: case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft { 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: case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning { if cm.Status != models.CampaignStatusRunning {
errMsg = "Only active campaigns can be paused" errMsg = app.i18n.T("campaigns.onlyActivePause")
} }
case models.CampaignStatusCancelled: case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused { 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) res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
if err != nil { if err != nil {
app.log.Printf("error updating campaign status: %v", err) app.log.Printf("error updating campaign status: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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) return handleGetCampaigns(c)
@ -416,24 +435,29 @@ func handleDeleteCampaign(c echo.Context) error {
) )
if id < 1 { 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 var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows { 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) app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
app.log.Printf("error deleting campaign: %v", err) app.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) 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) app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { } else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}}) return c.JSON(http.StatusOK, okResp{[]struct{}{}})
} }
@ -488,7 +513,7 @@ func handleTestCampaign(c echo.Context) error {
) )
if campID < 1 { 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. // Get and validate fields.
@ -503,7 +528,7 @@ func handleTestCampaign(c echo.Context) error {
req = c req = c
} }
if len(req.SubscriberEmails) == 0 { 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. // Get the subscribers.
@ -514,21 +539,25 @@ func handleTestCampaign(c echo.Context) error {
if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil { if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
app.log.Printf("error fetching subscribers: %v", err) app.log.Printf("error fetching subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { } 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. // The campaign.
var camp models.Campaign var camp models.Campaign
if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil { if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
if err == sql.ErrNoRows { 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) app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Override certain values in the DB with incoming values.
@ -544,8 +573,8 @@ func handleTestCampaign(c echo.Context) error {
for _, s := range subs { for _, s := range subs {
sub := s sub := s
if err := sendTestMessage(sub, &camp, app); err != nil { if err := sendTestMessage(sub, &camp, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error sending test: %v", err)) 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 { func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err) 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. // Render the message body.
m := app.manager.NewCampaignMessage(camp, sub) m := app.manager.NewCampaignMessage(camp, sub)
if err := m.Render(); err != nil { if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err) app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusNotFound,
fmt.Sprintf("Error rendering message: %v", err)) app.i18n.Ts2("templates.errorRendering", "error", err.Error()))
} }
return app.messengers[camp.Messenger].Push(messenger.Message{ 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 c.FromEmail = app.constants.FromEmail
} else if !regexFromAddress.Match([]byte(c.FromEmail)) { } else if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !subimporter.IsEmail(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) { 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) { 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) { // 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 there's a "send_at" date, it should be in the future.
if c.SendAt.Valid { if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) { 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 { 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) { 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} camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { 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 return c, nil
@ -633,7 +663,7 @@ func isCampaignalMutable(status string) bool {
// makeOptinCampaignMessage makes a default opt-in campaign message body. // makeOptinCampaignMessage makes a default opt-in campaign message body.
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
if len(o.ListIDs) == 0 { 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. // 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 { if err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return o, echo.NewHTTPError(http.StatusInternalServerError, 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. // No opt-in lists.
if len(lists) == 0 { if len(lists) == 0 {
return o, echo.NewHTTPError(http.StatusBadRequest, return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noOptinLists"))
"No opt-in lists found to create campaign.")
} }
// Construct the opt-in URL with list IDs. // Construct the opt-in URL with list IDs.
@ -666,8 +696,8 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
OptinURLAttr template.HTMLAttr OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil { }{lists, optinURLAttr}); err != nil {
app.log.Printf("error compiling 'optin-campaign' template: %v", err) app.log.Printf("error compiling 'optin-campaign' template: %v", err)
return o, echo.NewHTTPError(http.StatusInternalServerError, return o, echo.NewHTTPError(http.StatusBadRequest,
"Error compiling opt-in campaign template.") app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
} }
o.Body = b.String() o.Body = b.String()

View File

@ -2,6 +2,8 @@ package main
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@ -31,7 +33,10 @@ type pagination struct {
Limit int `json:"limit"` 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. // registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) { func registerHTTPHandlers(e *echo.Echo) {
@ -40,6 +45,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/", handleIndexPage) g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck) g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript) g.GET("/api/config.js", handleGetConfigScript)
g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts) g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -154,6 +160,23 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true}) 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. // basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) { func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App) 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. // validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc { func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
app := c.Get("app").(*App)
for _, p := range params { for _, p := range params {
if !reUUID.MatchString(c.Param(p)) { if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`One or more UUIDs in the request are invalid.`)) app.i18n.T("globals.messages.invalidUUID")))
} }
} }
return next(c) 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 { if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.log.Printf("error checking subscriber existence: %v", err) app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`Error processing request. Please retry.`)) app.i18n.T("public.errorProcessingRequest")))
} }
if !exists { if !exists {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl("Not found", "", makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
`Subscription not found.`)) app.i18n.T("public.subNotFound")))
} }
return next(c) 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 ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -27,30 +26,28 @@ func handleImportSubscribers(c echo.Context) error {
// Is an import already running? // Is an import already running?
if app.importer.GetStats().Status == subimporter.StatusImporting { if app.importer.GetStats().Status == subimporter.StatusImporting {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning"))
"An import is already running. Wait for it to finish or stop it before trying again.")
} }
// Unmarsal the JSON params. // Unmarsal the JSON params.
var r reqImport var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil { if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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 { 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 { if len(r.Delim) != 1 {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidDelim"))
"`delim` should be a single character")
} }
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `file`: %v", err)) app.i18n.Ts2("import.invalidFile", "error", err.Error()))
} }
src, err := file.Open() src, err := file.Open()
@ -62,20 +59,20 @@ func handleImportSubscribers(c echo.Context) error {
out, err := ioutil.TempFile("", "listmonk") out, err := ioutil.TempFile("", "listmonk")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err)) app.i18n.Ts2("import.errorCopyingFile", "error", err.Error()))
} }
defer out.Close() defer out.Close()
if _, err = io.Copy(out, src); err != nil { if _, err = io.Copy(out, src); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Start the importer session.
impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs) impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error starting import session: %v", err)) app.i18n.Ts2("import.errorStarting", "error", err.Error()))
} }
go impSess.Start() go impSess.Start()
@ -91,7 +88,7 @@ func handleImportSubscribers(c echo.Context) error {
dir, files, err := impSess.ExtractZIP(out.Name(), 1) dir, files, err := impSess.ExtractZIP(out.Name(), 1)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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])) 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/confmap"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/media/providers/filesystem" "github.com/knadh/listmonk/internal/media/providers/filesystem"
@ -44,6 +45,7 @@ type constants struct {
FaviconURL string `koanf:"favicon_url"` FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"` FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"` NotifyEmails []string `koanf:"notify_emails"`
Lang string `koanf:"lang"`
Privacy struct { Privacy struct {
IndividualTracking bool `koanf:"individual_tracking"` IndividualTracking bool `koanf:"individual_tracking"`
AllowBlocklist bool `koanf:"allow_blocklist"` 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/*. // Alias all files inside dist/ and dist/frontend to frontend/*.
"frontend/dist/favicon.png:/frontend/favicon.png", "frontend/dist/favicon.png:/frontend/favicon.png",
"frontend/dist/frontend:/frontend", "frontend/dist/frontend:/frontend",
"i18n:/i18n",
} }
fs, err = stuffbin.NewLocalFS("/", files...) fs, err = stuffbin.NewLocalFS("/", files...)
@ -230,6 +233,7 @@ func initConstants() *constants {
} }
c.RootURL = strings.TrimRight(c.RootURL, "/") c.RootURL = strings.TrimRight(c.RootURL, "/")
c.Lang = ko.String("app.lang")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider") c.MediaProvider = ko.String("upload.provider")
@ -251,6 +255,22 @@ func initConstants() *constants {
return &c 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. // initCampaignManager initializes the campaign manager.
func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
campNotifCB := func(subject string, data interface{}) error { 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 // initNotifTemplates compiles and returns e-mail notification templates that are
// used for sending ad-hoc notifications to admins and subscribers. // 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. // Register utility functions that the e-mail templates can use.
funcs := template.FuncMap{ funcs := template.FuncMap{
"RootURL": func() string { "RootURL": func() string {
@ -415,7 +435,11 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem
}, },
"LogoURL": func() string { "LogoURL": func() string {
return cs.LogoURL return cs.LogoURL
}} },
"L": func() *i18n.I18nLang {
return i
},
}
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html") tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
if err != nil { 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 { 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) app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 { if len(out.Results) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}}) return c.JSON(http.StatusOK, okResp{[]struct{}{}})
@ -93,14 +95,14 @@ func handleCreateList(c echo.Context) error {
// Validate. // Validate.
if !strHasLen(o.Name, 1, stdInputMaxLen) { if !strHasLen(o.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
"Invalid length for the name field.")
} }
uu, err := uuid.NewV4() uu, err := uuid.NewV4()
if err != nil { if err != nil {
app.log.Printf("error generating UUID: %v", err) 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. // Insert and read ID.
@ -114,7 +116,8 @@ func handleCreateList(c echo.Context) error {
pq.StringArray(normalizeTags(o.Tags))); err != nil { pq.StringArray(normalizeTags(o.Tags))); err != nil {
app.log.Printf("error creating list: %v", err) app.log.Printf("error creating list: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Hand over to the GET handler to return the last insertion.
@ -131,7 +134,7 @@ func handleUpdateList(c echo.Context) error {
) )
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} }
// Incoming params. // Incoming params.
@ -144,12 +147,14 @@ func handleUpdateList(c echo.Context) error {
o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags)))
if err != nil { if err != nil {
app.log.Printf("error updating list: %v", err) app.log.Printf("error updating list: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating list: %s", pqErrMsg(err))) app.i18n.Ts2("globals.messages.errorUpdating",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
} }
if n, _ := res.RowsAffected(); n == 0 { 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) return handleGetLists(c)
@ -165,7 +170,7 @@ func handleDeleteLists(c echo.Context) error {
) )
if id < 1 && len(ids) == 0 { 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 { if id > 0 {
@ -175,7 +180,8 @@ func handleDeleteLists(c echo.Context) error {
if _, err := app.queries.DeleteLists.Exec(ids); err != nil { if _, err := app.queries.DeleteLists.Exec(ids); err != nil {
app.log.Printf("error deleting lists: %v", err) app.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) return c.JSON(http.StatusOK, okResp{true})

View File

@ -17,6 +17,7 @@ import (
"github.com/knadh/koanf" "github.com/knadh/koanf"
"github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/env"
"github.com/knadh/listmonk/internal/buflog" "github.com/knadh/listmonk/internal/buflog"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
@ -39,6 +40,7 @@ type App struct {
importer *subimporter.Importer importer *subimporter.Importer
messengers map[string]messenger.Messenger messengers map[string]messenger.Messenger
media media.Store media media.Store
i18n *i18n.I18nLang
notifTpls *template.Template notifTpls *template.Template
log *log.Logger log *log.Logger
bufLog *buflog.BufLog bufLog *buflog.BufLog
@ -148,10 +150,14 @@ func main() {
log: lo, log: lo,
bufLog: bufLog, bufLog: bufLog,
} }
// Load i18n language map.
app.i18n = initI18n(app.constants.Lang, fs)
_, app.queries = initQueries(queryFilePath, db, fs, true) _, app.queries = initQueries(queryFilePath, db, fs, true)
app.manager = initCampaignManager(app.queries, app.constants, app) app.manager = initCampaignManager(app.queries, app.constants, app)
app.importer = initImporter(app.queries, db, 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. // Initialize the default SMTP (`email`) messenger.
app.messengers[emailMsgr] = initSMTPMessenger(app.manager) app.messengers[emailMsgr] = initSMTPMessenger(app.manager)

View File

@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
@ -35,14 +34,14 @@ func handleUploadMedia(c echo.Context) error {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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. // Validate MIME type with the list of allowed types.
var typ = file.Header.Get("Content-type") var typ = file.Header.Get("Content-type")
if ok := validateMIME(typ, imageMimes); !ok { if ok := validateMIME(typ, imageMimes); !ok {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Unsupported file type (%s) uploaded.", typ)) app.i18n.Ts2("media.unsupportedFileType", "type", typ))
} }
// Generate filename // Generate filename
@ -51,8 +50,8 @@ func handleUploadMedia(c echo.Context) error {
// Read file contents in memory // Read file contents in memory
src, err := file.Open() src, err := file.Open()
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error reading file: %s", err)) app.i18n.Ts2("media.errorReadingFile", "error", err.Error()))
} }
defer src.Close() defer src.Close()
@ -62,7 +61,7 @@ func handleUploadMedia(c echo.Context) error {
app.log.Printf("error uploading file: %v", err) app.log.Printf("error uploading file: %v", err)
cleanUp = true cleanUp = true
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error uploading file: %s", err)) app.i18n.Ts2("media.errorUploading", "error", err.Error()))
} }
defer func() { defer func() {
@ -80,7 +79,7 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true cleanUp = true
app.log.Printf("error resizing image: %v", err) app.log.Printf("error resizing image: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error resizing image: %s", err)) app.i18n.Ts2("media.errorResizing", "error", err.Error()))
} }
// Upload thumbnail. // Upload thumbnail.
@ -89,13 +88,14 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true cleanUp = true
app.log.Printf("error saving thumbnail: %v", err) app.log.Printf("error saving thumbnail: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error saving thumbnail: %s", err)) app.i18n.Ts2("media.errorSavingThumbnail", "error", err.Error()))
} }
uu, err := uuid.NewV4() uu, err := uuid.NewV4()
if err != nil { if err != nil {
app.log.Printf("error generating UUID: %v", err) 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. // Write to the DB.
@ -103,7 +103,8 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true cleanUp = true
app.log.Printf("error inserting uploaded file to db: %v", err) app.log.Printf("error inserting uploaded file to db: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) 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 { if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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++ { for i := 0; i < len(out); i++ {
@ -136,13 +138,14 @@ func handleDeleteMedia(c echo.Context) error {
) )
if id < 1 { 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 var m media.Media
if err := app.queries.DeleteMedia.Get(&m, id); err != nil { if err := app.queries.DeleteMedia.Get(&m, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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) app.media.Delete(m.Filename)
@ -160,8 +163,7 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
img, err := imaging.Decode(src) img, err := imaging.Decode(src)
if err != nil { if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, return nil, err
fmt.Sprintf("Error decoding image: %v", err))
} }
// Encode the image into a byte slice as PNG. // Encode the image into a byte slice as PNG.

View File

@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
@ -38,6 +39,7 @@ type tplData struct {
LogoURL string LogoURL string
FaviconURL string FaviconURL string
Data interface{} Data interface{}
L *i18n.I18nLang
} }
type publicTpl struct { type publicTpl struct {
@ -82,6 +84,7 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
LogoURL: t.LogoURL, LogoURL: t.LogoURL,
FaviconURL: t.FaviconURL, FaviconURL: t.FaviconURL,
Data: data, 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 := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage, 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) app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, 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. // 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 := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage, 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) app.log.Printf("error fetching campaign subscriber: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, 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. // Compile the template.
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err) app.log.Printf("error compiling template: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, 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. // Render the message body.
@ -132,7 +140,8 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := m.Render(); err != nil { if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err) app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, 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())) return c.HTML(http.StatusOK, string(m.Body()))
@ -151,7 +160,7 @@ func handleSubscriptionPage(c echo.Context) error {
out = unsubTpl{} out = unsubTpl{}
) )
out.SubUUID = subUUID out.SubUUID = subUUID
out.Title = "Unsubscribe from mailing list" out.Title = app.i18n.T("public.unsubscribeTitle")
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
out.AllowExport = app.constants.Privacy.AllowExport out.AllowExport = app.constants.Privacy.AllowExport
out.AllowWipe = app.constants.Privacy.AllowWipe 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 { if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
app.log.Printf("error unsubscribing: %v", err) app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`Error processing request. Please retry.`)) app.i18n.Ts2("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Unsubscribed", "", makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
`You have been successfully unsubscribed.`)) app.i18n.T("public.unsubbedInfo")))
} }
return c.Render(http.StatusOK, "subscription", out) return c.Render(http.StatusOK, "subscription", out)
@ -189,7 +198,7 @@ func handleOptinPage(c echo.Context) error {
out = optinTpl{} out = optinTpl{}
) )
out.SubUUID = subUUID out.SubUUID = subUUID
out.Title = "Confirm subscriptions" out.Title = app.i18n.T("public.confirmOptinSubTitle")
out.SubUUID = subUUID out.SubUUID = subUUID
// Get and validate fields. // Get and validate fields.
@ -202,8 +211,8 @@ func handleOptinPage(c echo.Context) error {
for _, l := range out.ListUUIDs { for _, l := range out.ListUUIDs {
if !reUUID.MatchString(l) { if !reUUID.MatchString(l) {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`One or more UUIDs in the request are invalid.`)) 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, if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil { nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return c.Render(http.StatusInternalServerError, tplMessage, 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. // There are no lists to confirm.
if len(out.Lists) == 0 { if len(out.Lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("No subscriptions", "", makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
`There are no subscriptions to confirm.`)) app.i18n.Ts2("public.noSubInfo")))
} }
// Confirm. // Confirm.
@ -228,12 +239,13 @@ func handleOptinPage(c echo.Context) error {
if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil { if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
app.log.Printf("error unsubscribing: %v", err) app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`Error processing request. Please retry.`)) app.i18n.Ts2("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Confirmed", "", makeMsgTpl(app.i18n.T("public.subsConfirmedTitle"), "",
`Your subscriptions have been confirmed.`)) app.i18n.Ts2("public.subConfirmed")))
} }
return c.Render(http.StatusOK, "optin", out) return c.Render(http.StatusOK, "optin", out)
@ -253,9 +265,9 @@ func handleSubscriptionForm(c echo.Context) error {
} }
if len(req.SubListUUIDs) == 0 { if len(req.SubListUUIDs) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Error", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`No lists to subscribe to.`)) app.i18n.T("globals.messages.invalidUUID")))
} }
// If there's no name, use the name bit from the e-mail. // 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. // Validate fields.
if err := subimporter.ValidateFields(req.SubReq); err != nil { if err := subimporter.ValidateFields(req.SubReq); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", err.Error())) makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
} }
// Insert the subscriber into the DB. // Insert the subscriber into the DB.
@ -275,11 +287,12 @@ func handleSubscriptionForm(c echo.Context) error {
req.ListUUIDs = pq.StringArray(req.SubListUUIDs) req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
if _, err := insertSubscriber(req.SubReq, app); err != nil { if _, err := insertSubscriber(req.SubReq, app); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage, 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, 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 // 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 err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
return c.Render(http.StatusNotFound, tplMessage, 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) app.log.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage, 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) return c.Redirect(http.StatusTemporaryRedirect, url)
@ -352,7 +367,8 @@ func handleSelfExportSubscriberData(c echo.Context) error {
// Is export allowed? // Is export allowed?
if !app.constants.Privacy.AllowExport { if !app.constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, tplMessage, 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, // 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 { if err != nil {
app.log.Printf("error exporting subscriber data: %s", err) app.log.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error processing request", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
"There was an error processing your request. Please try later.")) app.i18n.Ts2("public.errorProcessingRequest")))
} }
// Prepare the attachment e-mail. // Prepare the attachment e-mail.
var msg bytes.Buffer var msg bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
app.log.Printf("error compiling notification template '%s': %v", app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
notifSubscriberData, err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error preparing data", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
"There was an error preparing your data. Please try later.")) app.i18n.Ts2("public.errorProcessingRequest")))
} }
// Send the data as a JSON attachment to the subscriber. // Send the data as a JSON attachment to the subscriber.
@ -393,12 +408,13 @@ func handleSelfExportSubscriberData(c echo.Context) error {
}); err != nil { }); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err) app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error e-mailing data", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
"There was an error e-mailing your data. Please try later.")) app.i18n.Ts2("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Data e-mailed", "", makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
`Your data has been e-mailed to you as an attachment.`)) app.i18n.T("public.dataSent")))
} }
// handleWipeSubscriberData allows a subscriber to delete their data. The // handleWipeSubscriberData allows a subscriber to delete their data. The
@ -413,20 +429,20 @@ func handleWipeSubscriberData(c echo.Context) error {
// Is wiping allowed? // Is wiping allowed?
if !app.constants.Privacy.AllowWipe { if !app.constants.Privacy.AllowWipe {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
"The feature is not available.")) app.i18n.Ts2("public.invalidFeature")))
} }
if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil { if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
app.log.Printf("error wiping subscriber data: %s", err) app.log.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error processing request", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
"There was an error processing your request. Please try later.")) app.i18n.Ts2("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Data removed", "", makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
`Your subscriptions and all associated data has been removed.`)) app.i18n.T("public.dataRemoved")))
} }
// drawTransparentImage draws a transparent PNG of given dimensions // drawTransparentImage draws a transparent PNG of given dimensions

View File

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@ -15,15 +14,17 @@ import (
) )
type settings struct { type settings struct {
AppRootURL string `json:"app.root_url"` AppRootURL string `json:"app.root_url"`
AppLogoURL string `json:"app.logo_url"` AppLogoURL string `json:"app.logo_url"`
AppFaviconURL string `json:"app.favicon_url"` AppFaviconURL string `json:"app.favicon_url"`
AppFromEmail string `json:"app.from_email"` AppFromEmail string `json:"app.from_email"`
AppNotifyEmails []string `json:"app.notify_emails"` AppNotifyEmails []string `json:"app.notify_emails"`
AppBatchSize int `json:"app.batch_size"` AppLang string `json:"app.lang"`
AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"` AppBatchSize int `json:"app.batch_size"`
AppMessageRate int `json:"app.message_rate"` AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"`
AppMessageRate int `json:"app.message_rate"`
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
@ -144,8 +145,7 @@ func handleUpdateSettings(c echo.Context) error {
} }
} }
if !has { if !has {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
"At least one SMTP block should be enabled.")
} }
// Validate and sanitize postback Messenger names. Duplicates are disallowed // 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), "") name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "")
if _, ok := names[name]; ok { if _, ok := names[name]; ok {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Duplicate messenger name `%s`.", name)) app.i18n.Ts2("settings.duplicateMessengerName", "name", name))
} }
if len(name) == 0 { 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 set.Messengers[i].Name = name
@ -188,13 +188,14 @@ func handleUpdateSettings(c echo.Context) error {
b, err := json.Marshal(set) b, err := json.Marshal(set)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Update the settings in the DB.
if _, err := app.queries.UpdateSettings.Exec(b); err != nil { if _, err := app.queries.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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 // 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 { if err := app.queries.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError, 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. // Unmarshall the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil { if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError, return out, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error parsing settings: %v", err)) app.i18n.Ts2("settings.errorEncoding", "error", err.Error()))
} }
return out, nil return out, nil

View File

@ -101,7 +101,7 @@ func handleQuerySubscribers(c echo.Context) error {
listIDs := pq.Int64Array{} listIDs := pq.Int64Array{}
if listID < 0 { 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 { } else if listID > 0 {
listIDs = append(listIDs, int64(listID)) 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}) tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil { if err != nil {
app.log.Printf("error preparing subscriber query: %v", err) app.log.Printf("error preparing subscriber query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error preparing subscriber query: %v", pqErrMsg(err))) app.i18n.Ts2("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
} }
defer tx.Rollback() defer tx.Rollback()
// Run the query. stmt is the raw SQL query. // Run the query. stmt is the raw SQL query.
if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil { if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error fetching subscriber lists: %v", err) app.log.Printf("error fetching subscriber lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 out.Query = query
@ -196,13 +198,13 @@ func handleUpdateSubscriber(c echo.Context) error {
} }
if id < 1 { 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) { 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) { 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, _, err := app.queries.UpdateSubscriber.Exec(req.ID,
@ -214,7 +216,8 @@ func handleUpdateSubscriber(c echo.Context) error {
if err != nil { if err != nil {
app.log.Printf("error updating subscriber: %v", err) app.log.Printf("error updating subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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). // 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 { 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. // Fetch the subscriber.
@ -244,15 +247,17 @@ func handleSubscriberSendOptin(c echo.Context) error {
if err != nil { if err != nil {
app.log.Printf("error fetching subscriber: %v", err) app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 { if err := sendOptinConfirmation(out[0], nil, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
"Error sending opt-in e-mail.") app.i18n.T("subscribers.errorSendingOptin"))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -271,7 +276,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if pID != "" { if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64) id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 { 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) IDs = append(IDs, id)
} else { } else {
@ -279,7 +284,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
var req subQueryReq var req subQueryReq
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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 { if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
@ -291,7 +296,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil { if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil {
app.log.Printf("error blocklisting subscribers: %v", err) app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) return c.JSON(http.StatusOK, okResp{true})
@ -311,7 +316,7 @@ func handleManageSubscriberLists(c echo.Context) error {
if pID != "" { if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64) id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 { 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) IDs = append(IDs, id)
} }
@ -319,17 +324,16 @@ func handleManageSubscriberLists(c echo.Context) error {
var req subQueryReq var req subQueryReq
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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 { if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
"No IDs given.")
} }
if len(IDs) == 0 { if len(IDs) == 0 {
IDs = req.SubscriberIDs IDs = req.SubscriberIDs
} }
if len(req.TargetListIDs) == 0 { if len(req.TargetListIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No lists given.") return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
} }
// Action. // Action.
@ -342,13 +346,14 @@ func handleManageSubscriberLists(c echo.Context) error {
case "unsubscribe": case "unsubscribe":
_, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs) _, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
default: default:
return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.") return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
} }
if err != nil { if err != nil {
app.log.Printf("error updating subscriptions: %v", err) app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) return c.JSON(http.StatusOK, okResp{true})
@ -367,7 +372,7 @@ func handleDeleteSubscribers(c echo.Context) error {
if pID != "" { if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64) id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 { 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) IDs = append(IDs, id)
} else { } else {
@ -375,11 +380,11 @@ func handleDeleteSubscribers(c echo.Context) error {
i, err := parseStringIDs(c.Request().URL.Query()["id"]) i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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 { if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
"No IDs given.") app.i18n.Ts2("subscribers.errorNoIDs", "error", err.Error()))
} }
IDs = i IDs = i
} }
@ -387,7 +392,8 @@ func handleDeleteSubscribers(c echo.Context) error {
if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil { if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
app.log.Printf("error deleting subscribers: %v", err) app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, 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}) return c.JSON(http.StatusOK, okResp{true})
@ -409,9 +415,10 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
app.queries.DeleteSubscribersByQuery, app.queries.DeleteSubscribersByQuery,
req.ListIDs, app.db) req.ListIDs, app.db)
if err != nil { if err != nil {
app.log.Printf("error querying subscribers: %v", err) app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error: %v", err)) app.i18n.Ts2("globals.messages.errorDeleting",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -434,8 +441,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
req.ListIDs, app.db) req.ListIDs, app.db)
if err != nil { if err != nil {
app.log.Printf("error blocklisting subscribers: %v", err) app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error: %v", err)) app.i18n.Ts2("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -453,7 +460,8 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
return err return err
} }
if len(req.TargetListIDs) == 0 { if len(req.TargetListIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No lists given.") return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.T("subscribers.errorNoListsGiven"))
} }
// Action. // Action.
@ -466,15 +474,16 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
case "unsubscribe": case "unsubscribe":
stmt = app.queries.UnsubscribeSubscribersFromListsByQuery stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
default: 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), err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
stmt, req.ListIDs, app.db, req.TargetListIDs) stmt, req.ListIDs, app.db, req.TargetListIDs)
if err != nil { if err != nil {
app.log.Printf("error updating subscriptions: %v", err) app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error: %v", err)) app.i18n.Ts2("globals.messages.errorUpdating",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -491,7 +500,7 @@ func handleExportSubscriberData(c echo.Context) error {
) )
id, _ := strconv.ParseInt(pID, 10, 64) id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 { 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, // 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) _, b, err := exportSubscriberData(id, "", app.constants.Privacy.Exportable, app)
if err != nil { if err != nil {
app.log.Printf("error exporting subscriber data: %s", err) app.log.Printf("error exporting subscriber data: %s", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
"Error exporting subscriber data.") app.i18n.Ts2("globals.messages.errorFetching",
"name", "globals.terms.subscribers", "error", err.Error()))
} }
c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Cache-Control", "no-cache")
@ -527,12 +537,14 @@ func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, erro
req.ListUUIDs) req.ListUUIDs)
if err != nil { if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { 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) app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError, 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. // Fetch the subscriber's full data.
@ -553,21 +565,25 @@ func getSubscriber(id int, app *App) (models.Subscriber, error) {
) )
if id < 1 { 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 { if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil {
app.log.Printf("error fetching subscriber: %v", err) app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 { if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error loading subscriber lists: %v", err) app.log.Printf("error loading subscriber lists: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, 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 return out[0], nil
@ -647,8 +663,8 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
// Send the e-mail. // Send the e-mail.
if err := app.sendNotification([]string{sub.Email}, if err := app.sendNotification([]string{sub.Email},
"Confirm subscription", notifSubscriberOptin, out); err != nil { app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err) app.log.Printf("error sending opt-in e-mail: %s", err)
return err return err
} }
return nil return nil

View File

@ -50,16 +50,17 @@ func handleGetTemplates(c echo.Context) error {
err := app.queries.GetTemplates.Select(&out, id, noBody) err := app.queries.GetTemplates.Select(&out, id, noBody)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 { if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}}) return c.JSON(http.StatusOK, okResp{[]struct{}{}})
} } else if single {
if single {
return c.JSON(http.StatusOK, okResp{out[0]}) return c.JSON(http.StatusOK, okResp{out[0]})
} }
@ -79,21 +80,23 @@ func handlePreviewTemplate(c echo.Context) error {
if body != "" { if body != "" {
if !regexpTplTag.MatchString(body) { if !regexpTplTag.MatchString(body) {
return echo.NewHTTPError(http.StatusBadRequest, 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 { } else {
if id < 1 { 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) err := app.queries.GetTemplates.Select(&tpls, id, false)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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 body = tpls[0].Body
} }
@ -101,22 +104,23 @@ func handlePreviewTemplate(c echo.Context) error {
// Compile the template. // Compile the template.
camp := models.Campaign{ camp := models.Campaign{
UUID: dummyUUID, UUID: dummyUUID,
Name: "Dummy Campaign", Name: app.i18n.T("templates.dummyName"),
Subject: "Dummy Campaign Subject", Subject: app.i18n.T("templates.dummySubject"),
FromEmail: "dummy-campaign@listmonk.app", FromEmail: "dummy-campaign@listmonk.app",
TemplateBody: body, TemplateBody: body,
Body: dummyTpl, Body: dummyTpl,
} }
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { 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. // Render the message body.
m := app.manager.NewCampaignMessage(&camp, dummySubscriber) m := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err := m.Render(); err != nil { if err := m.Render(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, 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())) return c.HTML(http.StatusOK, string(m.Body()))
@ -133,7 +137,7 @@ func handleCreateTemplate(c echo.Context) error {
return err return err
} }
if err := validateTemplate(o); err != nil { if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} }
@ -143,7 +147,8 @@ func handleCreateTemplate(c echo.Context) error {
o.Name, o.Name,
o.Body); err != nil { o.Body); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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. // Hand over to the GET handler to return the last insertion.
@ -160,7 +165,7 @@ func handleUpdateTemplate(c echo.Context) error {
) )
if id < 1 { 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 var o models.Template
@ -168,7 +173,7 @@ func handleUpdateTemplate(c echo.Context) error {
return err return err
} }
if err := validateTemplate(o); err != nil { if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 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) res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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 { 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) return handleGetTemplates(c)
@ -194,13 +201,14 @@ func handleTemplateSetDefault(c echo.Context) error {
) )
if id < 1 { 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) _, err := app.queries.SetDefaultTemplate.Exec(id)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, 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) return handleGetTemplates(c)
@ -214,9 +222,10 @@ func handleDeleteTemplate(c echo.Context) error {
) )
if id < 1 { 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 { } 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 var delID int
@ -226,26 +235,28 @@ func handleDeleteTemplate(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
} }
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting template: %v", err)) app.i18n.Ts2("globals.messages.errorCreating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
} }
if delID == 0 { if delID == 0 {
return echo.NewHTTPError(http.StatusBadRequest, 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}) return c.JSON(http.StatusOK, okResp{true})
} }
// validateTemplate validates template fields. // validateTemplate validates template fields.
func validateTemplate(o models.Template) error { func validateTemplate(o models.Template, app *App) error {
if !strHasLen(o.Name, 1, stdInputMaxLen) { 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) { 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 return nil

View File

@ -28,6 +28,7 @@ var migList = []migFunc{
{"v0.4.0", migrations.V0_4_0}, {"v0.4.0", migrations.V0_4_0},
{"v0.7.0", migrations.V0_7_0}, {"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_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 // upgrade upgrades the database to the current version by running SQL migration files

View File

@ -26,6 +26,7 @@
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-c3": "^1.2.11", "vue-c3": "^1.2.11",
"vue-i18n": "^8.22.2",
"vue-quill-editor": "^3.0.6", "vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0", "vue-router": "^3.2.0",
"vuex": "^3.4.0" "vuex": "^3.4.0"

View File

@ -28,16 +28,16 @@
<b-menu-list> <b-menu-list>
<b-menu-item :to="{name: 'dashboard'}" tag="router-link" <b-menu-item :to="{name: 'dashboard'}" tag="router-link"
:active="activeItem.dashboard" :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><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists" <b-menu-item :expanded="activeGroup.lists"
:active="activeGroup.lists" :active="activeGroup.lists"
v-on:update:active="(state) => toggleGroup('lists', state)" 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" <b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists" :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" <b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms" :active="activeItem.forms"
@ -47,10 +47,10 @@
<b-menu-item :expanded="activeGroup.subscribers" <b-menu-item :expanded="activeGroup.subscribers"
:active="activeGroup.subscribers" :active="activeGroup.subscribers"
v-on:update:active="(state) => toggleGroup('subscribers', state)" 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" <b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers" :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" <b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import" :active="activeItem.import"
@ -60,36 +60,36 @@
<b-menu-item :expanded="activeGroup.campaigns" <b-menu-item :expanded="activeGroup.campaigns"
:active="activeGroup.campaigns" :active="activeGroup.campaigns"
v-on:update:active="(state) => toggleGroup('campaigns', state)" 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" <b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns" :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" <b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign" :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" <b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media" :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" <b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates" :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><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings" <b-menu-item :expanded="activeGroup.settings"
:active="activeGroup.settings" :active="activeGroup.settings"
v-on:update:active="(state) => toggleGroup('settings', state)" 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" <b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings" :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" <b-menu-item :to="{name: 'logs'}" tag="router-link"
:active="activeItem.logs" :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-item><!-- settings -->
</b-menu-list> </b-menu-list>
</b-menu> </b-menu>

View File

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

View File

@ -1,6 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import Buefy from 'buefy'; import Buefy from 'buefy';
import humps from 'humps'; import humps from 'humps';
import VueI18n from 'vue-i18n';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
@ -9,6 +10,12 @@ import * as api from './api';
import utils from './utils'; import utils from './utils';
import { models } from './constants'; import { models } from './constants';
// Internationalisation.
Vue.use(VueI18n);
// Create VueI18n instance with options
const i18n = new VueI18n();
Vue.use(Buefy, {}); Vue.use(Buefy, {});
Vue.config.productionTip = false; Vue.config.productionTip = false;
@ -36,10 +43,15 @@ Vue.prototype.$reloadServerConfig = () => {
if (window.CONFIG) { if (window.CONFIG) {
store.commit('setModelResponse', store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) }); { 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({ new Vue({
router, router,
store, store,
i18n,
render: (h) => h(App), render: (h) => h(App),
}).$mount('#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="isEditing" :class="data.status">{{ data.status }}</b-tag>
<b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</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"> <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> </span>
</p> </p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4> <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>
<div class="column"> <div class="column">
<div class="buttons" v-if="isEditing && canEdit"> <div class="buttons" v-if="isEditing && canEdit">
<b-button @click="onSubmit" :loading="loading.campaigns" <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" <b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline"> type="is-primary" icon-left="rocket-launch-outline">
Start campaign {{ $t('campaigns.start') }}
</b-button> </b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns" <b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start"> type="is-primary" icon-left="clock-start">
Schedule campaign {{ $t('campaigns.schedule') }}
</b-button> </b-button>
</div> </div>
</div> </div>
@ -33,24 +35,25 @@
<b-loading :active="loading.campaigns"></b-loading> <b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab"> <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"> <section class="wrap">
<div class="columns"> <div class="columns">
<div class="column is-7"> <div class="column is-7">
<form @submit.prevent="onSubmit"> <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" <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>
<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" <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>
<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" <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> </b-field>
<list-selector <list-selector
@ -58,35 +61,35 @@
:selected="form.lists" :selected="form.lists"
:all="lists.results" :all="lists.results"
:disabled="!canEdit" :disabled="!canEdit"
label="Lists" :label="$t('globals.terms.lists')"
placeholder="Lists to send to" :placeholder="$t('campaigns.sendToLists')"
></list-selector> ></list-selector>
<b-field label="Template" label-position="on-border"> <b-field :label="$tc('terms.template')" label-position="on-border">
<b-select placeholder="Template" v-model="form.templateId" <b-select :placeholder="$tc('terms.template')" v-model="form.templateId"
:disabled="!canEdit" required> :disabled="!canEdit" required>
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option> <option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
</b-select> </b-select>
</b-field> </b-field>
<b-field label="Messenger" label-position="on-border"> <b-field :label="$tc('terms.messenger')" label-position="on-border">
<b-select placeholder="Messenger" v-model="form.messenger" <b-select :placeholder="$tc('terms.messenger')" v-model="form.messenger"
:disabled="!canEdit" required> :disabled="!canEdit" required>
<option v-for="m in serverConfig.messengers" <option v-for="m in serverConfig.messengers"
:value="m" :key="m">{{ m }}</option> :value="m" :key="m">{{ m }}</option>
</b-select> </b-select>
</b-field> </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" <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> </b-field>
<hr /> <hr />
<div class="columns"> <div class="columns">
<div class="column is-2"> <div class="column is-2">
<b-field label="Send later?"> <b-field :label="$t('campaigns.sendLater')">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch> <b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field> </b-field>
</div> </div>
<div class="column"> <div class="column">
@ -96,7 +99,7 @@
<b-datetimepicker <b-datetimepicker
v-model="form.sendAtDate" v-model="form.sendAtDate"
:disabled="!canEdit" :disabled="!canEdit"
placeholder="Date and time" :placeholder="$t('dateAndTime')"
icon="calendar-clock" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime" :datetime-formatter="formatDateTime"
@ -109,23 +112,24 @@
<b-field v-if="isNew"> <b-field v-if="isNew">
<b-button native-type="submit" type="is-primary" <b-button native-type="submit" type="is-primary"
:loading="loading.campaigns">Continue</b-button> :loading="loading.campaigns">{{ $t('campaigns.continue') }}</b-button>
</b-field> </b-field>
</form> </form>
</div> </div>
<div class="column is-4 is-offset-1"> <div class="column is-4 is-offset-1">
<br /> <br />
<div class="box"> <div class="box">
<h3 class="title is-size-6">Send test message</h3> <h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
<b-field message="Hit Enter after typing an address to add multiple recipients. <b-field :message="$t('campaigns.sendTestHelp')">
The addresses must belong to existing subscribers."> <b-taginput v-model="form.testEmails"
<b-taginput v-model="form.testEmails"
:before-adding="$utils.validateEmail" :disabled="this.isNew" :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-field> <b-field>
<b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew" <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> </b-field>
</div> </div>
</div> </div>
@ -233,7 +237,7 @@ export default Vue.extend({
}; };
this.$api.testCampaign(data).then(() => { this.$api.testCampaign(data).then(() => {
this.$utils.toast('Test message sent'); this.$utils.toast(this.$t('campaigns.testSent'));
}); });
return false; return false;
}, },
@ -282,16 +286,16 @@ export default Vue.extend({
body: this.form.content.body, body: this.form.content.body,
}; };
let typMsg = 'updated'; let typMsg = 'globals.messages.updated';
if (typ === 'start') { if (typ === 'start') {
typMsg = 'started'; typMsg = 'campaigns.started';
} }
// This promise is used by startCampaign to first save before starting. // This promise is used by startCampaign to first save before starting.
return new Promise((resolve) => { return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => { this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d; this.data = d;
this.$utils.toast(`'${d.name}' ${typMsg}`); this.$utils.toast(this.$t(typMsg, { name: d.name }));
resolve(); resolve();
}); });
}); });
@ -373,7 +377,7 @@ export default Vue.extend({
} else { } else {
const intID = parseInt(id, 10); const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) { if (intID <= 0 || Number.isNaN(intID)) {
this.$utils.toast('Invalid campaign'); this.$utils.toast(this.$t('campaigns.invalid'));
return; return;
} }

View File

@ -2,20 +2,20 @@
<section class="campaigns"> <section class="campaigns">
<header class="columns"> <header class="columns">
<div class="column is-two-thirds"> <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> <span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span>
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link" <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> </div>
</header> </header>
<form @submit.prevent="getCampaigns"> <form @submit.prevent="getCampaigns">
<b-field grouped> <b-field grouped>
<b-input v-model="queryParams.query" <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-button native-type="submit" type="is-primary" icon-left="magnify"></b-button>
</b-field> </b-field>
</form> </form>
@ -40,7 +40,7 @@
</router-link> </router-link>
</p> </p>
<p v-if="isSheduled(props.row)"> <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"> <span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" /> <b-icon icon="alarm" size="is-small" />
{{ $utils.duration(Date(), props.row.sendAt, true) }} {{ $utils.duration(Date(), props.row.sendAt, true) }}
@ -50,7 +50,7 @@
</p> </p>
</div> </div>
</b-table-column> </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> <div>
<p> <p>
<b-tag v-if="props.row.type !== 'regular'" class="is-small"> <b-tag v-if="props.row.type !== 'regular'" class="is-small">
@ -65,7 +65,8 @@
</b-taglist> </b-taglist>
</div> </div>
</b-table-column> </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"> <ul class="no">
<li v-for="l in props.row.lists" :key="l.id"> <li v-for="l in props.row.lists" :key="l.id">
<router-link :to="{name: 'subscribers_list', params: { listID: l.id }}"> <router-link :to="{name: 'subscribers_list', params: { listID: l.id }}">
@ -74,7 +75,8 @@
</li> </li>
</ul> </ul>
</b-table-column> </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)"> <div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p> <p>
<label>Created</label> <label>Created</label>
@ -99,15 +101,15 @@
<b-table-column field="stats" :class="props.row.status" label="Stats" width="18%"> <b-table-column field="stats" :class="props.row.status" label="Stats" width="18%">
<div class="fields stats" :set="stats = getCampaignStats(props.row)"> <div class="fields stats" :set="stats = getCampaignStats(props.row)">
<p> <p>
<label>Views</label> <label>{{ $t('campaigns.views') }}</label>
{{ props.row.views }} {{ props.row.views }}
</p> </p>
<p> <p>
<label>Clicks</label> <label>{{ $t('campaigns.clicks') }}</label>
{{ props.row.clicks }} {{ props.row.clicks }}
</p> </p>
<p> <p>
<label>Sent</label> <label>{{ $t('campaigns.sent') }}</label>
{{ stats.sent }} / {{ stats.toSend }} {{ stats.sent }} / {{ stats.toSend }}
</p> </p>
<p title="Speed" v-if="stats.rate"> <p title="Speed" v-if="stats.rate">
@ -117,7 +119,7 @@
</span> </span>
</p> </p>
<p v-if="isRunning(props.row.id)"> <p v-if="isRunning(props.row.id)">
<label>Progress <label>{{ $t('campaigns.progress') }}
<span class="spinner is-tiny"> <span class="spinner is-tiny">
<b-loading :is-full-page="false" active /> <b-loading :is-full-page="false" active />
</span> </span>
@ -132,52 +134,52 @@
<a href="" v-if="canStart(props.row)" <a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null, @click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))"> () => 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-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" v-if="canPause(props.row)" <a href="" v-if="canPause(props.row)"
@click.prevent="$utils.confirm(null, @click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'paused'))"> () => 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-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" v-if="canResume(props.row)" <a href="" v-if="canResume(props.row)"
@click.prevent="$utils.confirm(null, @click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))"> () => 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-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" v-if="canSchedule(props.row)" <a href="" v-if="canSchedule(props.row)"
@click.prevent="$utils.confirm(`This campaign will start automatically at the @click.prevent="$utils.confirm($t('campaigns.confirmSchedule'),
scheduled date and time. Schedule now?`, () => changeCampaignStatus(props.row, 'scheduled'))">
() => changeCampaignStatus(props.row, 'scheduled'))"> <b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-tooltip label="Schedule" type="is-dark">
<b-icon icon="clock-start" size="is-small" /> <b-icon icon="clock-start" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" @click.prevent="previewCampaign(props.row)"> <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-icon icon="file-find-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" @click.prevent="$utils.prompt(`Clone campaign`, <a href="" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{ placeholder: 'Name', value: `Copy of ${props.row.name}`}, { placeholder: $t('globals.fields.name'),
(name) => cloneCampaign(name, props.row))"> value: $t('campaigns.copyOf', { name: props.row.name }) },
<b-tooltip label="Clone" type="is-dark"> (name) => cloneCampaign(name, props.row))">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" /> <b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" v-if="canCancel(props.row)" <a href="" v-if="canCancel(props.row)"
@click.prevent="$utils.confirm(null, @click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'cancelled'))"> () => 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-icon icon="cancel" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" @click.prevent="$utils.confirm(`Delete '${props.row.name}'?`, <a href="" @click.prevent="$utils.confirm($tc('campaigns.confirmDelete'),
() => deleteCampaign(props.row))"> () => deleteCampaign(props.row))">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />
</a> </a>
@ -331,7 +333,7 @@ export default Vue.extend({
changeCampaignStatus(c, status) { changeCampaignStatus(c, status) {
this.$api.changeCampaignStatus(c.id, status).then(() => { 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.getCampaigns();
this.pollStats(); this.pollStats();
}); });
@ -358,7 +360,7 @@ export default Vue.extend({
deleteCampaign(c) { deleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => { this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns(); 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="columns is-mobile">
<div class="column is-6"> <div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p> <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>
<div class="column is-6"> <div class="column is-6">
<ul class="no is-size-7 has-text-grey"> <ul class="no is-size-7 has-text-grey">
<li> <li>
<label>{{ $utils.niceNumber(counts.lists.public) }}</label> public <label>{{ $utils.niceNumber(counts.lists.public) }}</label>
{{ $t('lists.types.public') }}
</li> </li>
<li> <li>
<label>{{ $utils.niceNumber(counts.lists.private) }}</label> private <label>{{ $utils.niceNumber(counts.lists.private) }}</label>
{{ $t('lists.types.private') }}
</li> </li>
<li> <li>
<label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label> <label>{{ $utils.niceNumber(counts.lists.optinSingle) }}</label>
single opt-in {{ $t('lists.optins.single') }}
</li> </li>
<li> <li>
<label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label> <label>{{ $utils.niceNumber(counts.lists.optinDouble) }}</label>
double opt-in</li> {{ $t('lists.optins.double') }}
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -42,7 +47,9 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-6"> <div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p> <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>
<div class="column is-6"> <div class="column is-6">
<ul class="no is-size-7 has-text-grey"> <ul class="no is-size-7 has-text-grey">
@ -61,18 +68,20 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column is-6"> <div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p> <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>
<div class="column is-6"> <div class="column is-6">
<ul class="no is-size-7 has-text-grey"> <ul class="no is-size-7 has-text-grey">
<li> <li>
<label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label> <label>{{ $utils.niceNumber(counts.subscribers.blocklisted) }}</label>
blocklisted {{ $t('subscribers.status.blocklisted') }}
</li> </li>
<li> <li>
<label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label> <label>{{ $utils.niceNumber(counts.subscribers.orphans) }}</label>
orphans {{ $t('dashboard.orphanSubs') }}
</li> </li>
</ul> </ul>
</div><!-- subscriber breakdown --> </div><!-- subscriber breakdown -->
@ -81,7 +90,9 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p> <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>
</div> </div>
</article><!-- subscribers --> </article><!-- subscribers -->
@ -92,12 +103,14 @@
<article class="tile is-child notification charts"> <article class="tile is-child notification charts">
<div class="columns"> <div class="columns">
<div class="column is-6"> <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> <vue-c3 v-if="chartViewsInst" :handler="chartViewsInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" /> <empty-placeholder v-else-if="!isChartsLoading" />
</div> </div>
<div class="column is-6"> <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> <vue-c3 v-if="chartClicksInst" :handler="chartClicksInst"></vue-c3>
<empty-placeholder v-else-if="!isChartsLoading" /> <empty-placeholder v-else-if="!isChartsLoading" />
</div> </div>
@ -200,7 +213,7 @@ export default Vue.extend({
this.$nextTick(() => { this.$nextTick(() => {
this.chartViewsInst.$emit('init', 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.$nextTick(() => {
this.chartClicksInst.$emit('init', 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> <template>
<section class="forms content relative"> <section class="forms content relative">
<h1 class="title is-4">Forms</h1> <h1 class="title is-4">{{ $t('forms.title') }}</h1>
<hr /> <hr />
<b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" /> <b-loading v-if="loading.lists" :active="loading.lists" :is-full-page="false" />
<div class="columns" v-else-if="publicLists.length > 0"> <div class="columns" v-else-if="publicLists.length > 0">
<div class="column is-4"> <div class="column is-4">
<h4>Public lists</h4> <h4>{{ $t('forms.publicLists') }}</h4>
<p>Select lists to add to the form.</p> <p>{{ $t('forms.selectHelp') }}</p>
<b-loading :active="loading.lists" :is-full-page="false" /> <b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no"> <ul class="no">
@ -17,16 +17,13 @@
</ul> </ul>
</div> </div>
<div class="column"> <div class="column">
<h4>Form HTML</h4> <h4>{{ $t('forms.formHTML') }}</h4>
<p> <p>
Use the following HTML to show a subscription form on an external webpage. {{ $t('forms.formHTMLHelp') }}
</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.
</p> </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;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&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; &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>
</div><!-- columns --> </div><!-- columns -->
<p v-else>There are no public lists to create forms.</p> <p v-else></p>
</section> </section>
</template> </template>
@ -66,7 +63,7 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['lists', 'loading']), ...mapState(['lists', 'loading', 'serverConfig']),
publicLists() { publicLists() {
if (!this.lists.results) { if (!this.lists.results) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,19 +2,23 @@
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div class="modal-card" style="width: auto"> <div class="modal-card" style="width: auto">
<header class="modal-card-head"> <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> </header>
<section expanded class="modal-card-body"> <section expanded class="modal-card-body">
<b-field label="Action"> <b-field label="Action">
<div> <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="add">
<b-radio v-model="form.action" name="action" native-value="remove">Remove</b-radio> {{ $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 <b-radio
v-model="form.action" v-model="form.action"
name="action" name="action"
native-value="unsubscribe" native-value="unsubscribe"
>Mark as unsubscribed</b-radio> >{{ $t('subscribers.markUnsubscribed') }}</b-radio>
</div> </div>
</b-field> </b-field>
@ -28,9 +32,9 @@
</section> </section>
<footer class="modal-card-foot has-text-right"> <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" <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> </footer>
</div> </div>
</form> </form>

View File

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

View File

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

View File

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

View File

@ -2,60 +2,62 @@
<section class="templates"> <section class="templates">
<header class="columns"> <header class="columns">
<div class="column is-two-thirds"> <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> <span v-if="templates.length > 0">({{ templates.length }})</span></h1>
</div> </div>
<div class="column has-text-right"> <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> </div>
</header> </header>
<b-table :data="templates" :hoverable="true" :loading="loading.templates" <b-table :data="templates" :hoverable="true" :loading="loading.templates"
default-sort="createdAt"> default-sort="createdAt">
<template slot-scope="props"> <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)"> <a :href="props.row.id" @click.prevent="showEditForm(props.row)">
{{ props.row.name }} {{ props.row.name }}
</a> </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>
<b-table-column field="createdAt" label="Created" sortable> <b-table-column field="createdAt" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }} {{ $utils.niceDate(props.row.createdAt) }}
</b-table-column> </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) }} {{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column> </b-table-column>
<b-table-column class="actions" align="right"> <b-table-column class="actions" align="right">
<div> <div>
<a href="#" @click.prevent="previewTemplate(props.row)"> <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-icon icon="file-find-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="showEditForm(props.row)"> <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-icon icon="pencil-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" @click.prevent="$utils.prompt(`Clone template`, <a href="" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}`}, { placeholder: 'Name', value: `Copy of ${props.row.name}`},
(name) => cloneTemplate(name, props.row))"> (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-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a v-if="!props.row.isDefault" href="#" <a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"> @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-icon icon="check-circle-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a v-if="!props.row.isDefault" <a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"> 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-icon icon="trash-can-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
@ -151,7 +153,7 @@ export default Vue.extend({
this.$api.getTemplates(); this.$api.getTemplates();
this.$buefy.toast.open({ this.$buefy.toast.open({
message: `'${tpl.name}' made default`, message: this.$t('globals.messages.created', { name: tpl.name }),
type: 'is-success', type: 'is-success',
queue: false, queue: false,
}); });
@ -163,7 +165,7 @@ export default Vue.extend({
this.$api.getTemplates(); this.$api.getTemplates();
this.$buefy.toast.open({ this.$buefy.toast.open({
message: `'${tpl.name}' deleted`, message: this.$t('globals.messages.deleted', { name: tpl.name }),
type: 'is-success', type: 'is-success',
queue: false, 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" resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog== 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: vue-loader@^15.9.2:
version "15.9.2" version "15.9.2"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.2.tgz#ae01f5f4c9c6a04bff4483912e72ef91a402c1ae" 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 gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195 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.batch_size', '1000'),
('app.max_send_errors', '1000'), ('app.max_send_errors', '1000'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('app.lang', '"en"'),
('privacy.individual_tracking', 'false'), ('privacy.individual_tracking', 'false'),
('privacy.unsubscribe_header', 'true'), ('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'), ('privacy.allow_blocklist', 'true'),

View File

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

View File

@ -76,7 +76,10 @@
</div> </div>
<div class="footer" style="text-align: center;font-size: 12px;color: #888;"> <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> <p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
</div> </div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div> <div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>

View File

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

View File

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

View File

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

View File

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

View File

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