WIP: Add i18n support
This commit is contained in:
parent
dae47fbeaa
commit
3498a727f5
47 changed files with 1483 additions and 680 deletions
3
Makefile
3
Makefile
|
@ -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
|
||||||
|
|
53
cmd/admin.go
53
cmd/admin.go
|
@ -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})
|
||||||
|
|
148
cmd/campaigns.go
148
cmd/campaigns.go
|
@ -106,10 +106,12 @@ func handleGetCampaigns(c echo.Context) error {
|
||||||
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
|
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()
|
||||||
|
|
|
@ -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
44
cmd/i18n.go
Normal 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
|
||||||
|
}
|
|
@ -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]))
|
||||||
}
|
}
|
||||||
|
|
28
cmd/init.go
28
cmd/init.go
|
@ -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 {
|
||||||
|
|
30
cmd/lists.go
30
cmd/lists.go
|
@ -53,10 +53,12 @@ func handleGetLists(c echo.Context) error {
|
||||||
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
|
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})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
32
cmd/media.go
32
cmd/media.go
|
@ -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.
|
||||||
|
|
108
cmd/public.go
108
cmd/public.go
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
1
frontend/package.json
vendored
1
frontend/package.json
vendored
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 --><form method="post" action="http://localhost:9000/subscription/form" class="listmonk-form">
|
<!-- eslint-disable max-len -->
|
||||||
|
<pre><form method="post" action="{{ serverConfig.rootURL }}/subscription/form" class="listmonk-form">
|
||||||
<div>
|
<div>
|
||||||
<h3>Subscribe</h3>
|
<h3>Subscribe</h3>
|
||||||
<p><input type="text" name="email" placeholder="E-mail" /></p>
|
<p><input type="text" name="email" placeholder="E-mail" /></p>
|
||||||
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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">
|
||||||
» {{ currentList.name }}
|
» {{ 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">
|
||||||
— <a href="" @click.prevent="selectAllSubscribers">
|
—
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
5
frontend/yarn.lock
vendored
|
@ -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
2
go.mod
|
@ -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
363
i18n/en.json
Normal 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
161
internal/i18n/i18n.go
Normal 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)
|
||||||
|
}
|
16
internal/migrations/v0.9.0.go
Normal file
16
internal/migrations/v0.9.0.go
Normal 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
|
||||||
|
}
|
|
@ -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'),
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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;"> {{ TrackView }}</div>
|
<div class="gutter" style="padding: 30px;"> {{ TrackView }}</div>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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" }}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue