Merge branch 'i18n'

This commit is contained in:
Kailash Nadh 2021-02-07 12:06:06 +05:30
commit b6dcf2c841
65 changed files with 3583 additions and 906 deletions

View File

@ -23,7 +23,7 @@ builds:
hooks: hooks:
# stuff executables with static assets. # stuff executables with static assets.
post: make pack-bin bin={{ .Path }} post: make pack-bin BIN={{ .Path }}
archives: archives:
- format: tar.gz - format: tar.gz

View File

@ -8,7 +8,8 @@ STATIC := config.toml.sample \
static/public:/public \ static/public:/public \
static/email-templates \ static/email-templates \
frontend/dist/favicon.png:/frontend/favicon.png \ frontend/dist/favicon.png:/frontend/favicon.png \
frontend/dist/frontend:/frontend frontend/dist/frontend:/frontend \
i18n:/i18n
# Install dependencies for building. # Install dependencies for building.
.PHONY: deps .PHONY: deps
@ -51,7 +52,7 @@ dist: build build-frontend
# in the .goreleaser post-build hook. # in the .goreleaser post-build hook.
.PHONY: pack-bin .PHONY: pack-bin
pack-bin: pack-bin:
stuffbin -a stuff -in $(bin) -out $(bin) ${STATIC} stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC}
# Use goreleaser to do a dry run producing local builds. # Use goreleaser to do a dry run producing local builds.
.PHONY: release-dry .PHONY: release-dry

View File

@ -20,6 +20,9 @@ type configScript struct {
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"`
EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"`
Lang json.RawMessage `json:"lang"`
} }
// handleGetConfigScript returns general configuration as a Javascript // handleGetConfigScript returns general configuration as a Javascript
@ -31,9 +34,21 @@ func handleGetConfigScript(c echo.Context) error {
RootURL: app.constants.RootURL, RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail, FromEmail: app.constants.FromEmail,
MediaProvider: app.constants.MediaProvider, MediaProvider: app.constants.MediaProvider,
EnablePublicSubPage: app.constants.EnablePublicSubPage,
} }
) )
// Language list.
langList, err := geti18nLangList(app.constants.Lang, app)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error loading language list: %v", err))
}
out.Langs = langList
// Current language.
out.Lang = json.RawMessage(app.i18n.JSON())
// Sort messenger names with `email` always as the first item. // 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 +66,17 @@ 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", "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,7 @@ 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", "name", "dashboard charts", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})
@ -84,7 +103,7 @@ func handleGetDashboardCounts(c echo.Context) error {
if err := app.queries.GetDashboardCounts.Get(&out); err != nil { 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", "name", "dashboard stats", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})

View File

@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/jaytaylor/html2text"
"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"
@ -106,10 +107,12 @@ func handleGetCampaigns(c echo.Context) error {
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil { 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.Ts("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.Ts("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 +134,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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
if single { if single {
@ -146,7 +150,7 @@ func handleGetCampaigns(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})
} }
// handlePreviewTemplate renders the HTML preview of a campaign body. // handlePreviewCampaign renders the HTML preview of a campaign body.
func handlePreviewCampaign(c echo.Context) error { func handlePreviewCampaign(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
@ -157,18 +161,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.Ts("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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
var sub models.Subscriber var sub models.Subscriber
@ -180,7 +186,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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
} }
} }
@ -192,7 +199,7 @@ func handlePreviewCampaign(c echo.Context) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { 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.Ts("templates.errorCompiling", "error", err.Error()))
} }
// Render the message body. // Render the message body.
@ -200,12 +207,23 @@ 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.Ts("templates.errorRendering", "error", err.Error()))
} }
return c.HTML(http.StatusOK, string(m.Body())) return c.HTML(http.StatusOK, string(m.Body()))
} }
// handleCampainBodyToText converts an HTML campaign body to plaintext.
func handleCampainBodyToText(c echo.Context) error {
out, err := html2text.FromString(c.FormValue("body"),
html2text.Options{PrettyTables: false})
if err != nil {
return err
}
return c.HTML(http.StatusOK, string(out))
}
// handleCreateCampaign handles campaign creation. // handleCreateCampaign handles campaign creation.
// Newly created campaigns are always drafts. // Newly created campaigns are always drafts.
func handleCreateCampaign(c echo.Context) error { func handleCreateCampaign(c echo.Context) error {
@ -237,7 +255,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.Ts("globals.messages.errorUUID", "error", err.Error()))
} }
// Insert and read ID. // Insert and read ID.
@ -249,6 +268,7 @@ func handleCreateCampaign(c echo.Context) error {
o.Subject, o.Subject,
o.FromEmail, o.FromEmail,
o.Body, o.Body,
o.AltBody,
o.ContentType, o.ContentType,
o.SendAt, o.SendAt,
pq.StringArray(normalizeTags(o.Tags)), pq.StringArray(normalizeTags(o.Tags)),
@ -257,13 +277,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.Ts("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,27 +301,31 @@ 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.Ts("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.Ts("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. // Read the incoming params into the existing campaign fields from the DB.
var o campaignReq // This allows updating of values that have been sent where as fields
// that are not in the request retain the old values.
o := campaignReq{Campaign: cm}
if err := c.Bind(&o); err != nil { if err := c.Bind(&o); err != nil {
return err return err
} }
@ -317,6 +341,7 @@ func handleUpdateCampaign(c echo.Context) error {
o.Subject, o.Subject,
o.FromEmail, o.FromEmail,
o.Body, o.Body,
o.AltBody,
o.ContentType, o.ContentType,
o.SendAt, o.SendAt,
o.SendLater, o.SendLater,
@ -327,7 +352,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.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
return handleGetCampaigns(c) return handleGetCampaigns(c)
@ -341,18 +367,20 @@ 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("globals.message.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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
// Incoming params. // Incoming params.
@ -365,27 +393,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 +424,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.Ts("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.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
return handleGetCampaigns(c) return handleGetCampaigns(c)
@ -416,24 +448,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.Ts("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.Ts("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.Ts("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 +490,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.Ts("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 +526,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 +541,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,28 +552,33 @@ 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.Ts("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.Ts("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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
// Override certain values in the DB with incoming values. // Override certain values from the DB with incoming values.
camp.Name = req.Name camp.Name = req.Name
camp.Subject = req.Subject camp.Subject = req.Subject
camp.FromEmail = req.FromEmail camp.FromEmail = req.FromEmail
camp.Body = req.Body camp.Body = req.Body
camp.AltBody = req.AltBody
camp.Messenger = req.Messenger camp.Messenger = req.Messenger
camp.ContentType = req.ContentType camp.ContentType = req.ContentType
camp.TemplateID = req.TemplateID camp.TemplateID = req.TemplateID
@ -544,8 +587,9 @@ 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, app.log.Printf("error sending test message: %v", err)
fmt.Sprintf("Error sending test: %v", err)) return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("campaigns.errorSendTest", "error", err.Error()))
} }
} }
@ -556,15 +600,16 @@ func handleTestCampaign(c echo.Context) error {
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error { 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.Ts("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.Ts("templates.errorRendering", "error", err.Error()))
} }
return app.messengers[camp.Messenger].Push(messenger.Message{ return app.messengers[camp.Messenger].Push(messenger.Message{
@ -573,6 +618,7 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err
Subject: m.Subject(), Subject: m.Subject(),
ContentType: camp.ContentType, ContentType: camp.ContentType,
Body: m.Body(), Body: m.Body(),
AltBody: m.AltBody(),
Subscriber: sub, Subscriber: sub,
Campaign: camp, Campaign: camp,
}) })
@ -584,15 +630,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 +648,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.Ts("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.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
} }
return c, nil return c, nil
@ -633,7 +679,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 +688,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.Ts("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 +712,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.Ts("templates.errorCompiling", "error", err.Error()))
} }
o.Body = b.String() o.Body = b.String()

View File

@ -2,6 +2,8 @@ package main
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@ -31,7 +33,10 @@ type pagination struct {
Limit int `json:"limit"` Limit int `json:"limit"`
} }
var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") var (
reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9]")
)
// registerHandlers registers HTTP handlers. // registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) { func registerHTTPHandlers(e *echo.Echo) {
@ -40,6 +45,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/", handleIndexPage) g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck) g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript) g.GET("/api/config.js", handleGetConfigScript)
g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts) g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -66,6 +72,8 @@ func registerHTTPHandlers(e *echo.Echo) {
g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery) g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
g.GET("/api/subscribers", handleQuerySubscribers) g.GET("/api/subscribers", handleQuerySubscribers)
g.GET("/api/subscribers/export",
middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers))
g.GET("/api/import/subscribers", handleGetImportSubscribers) g.GET("/api/import/subscribers", handleGetImportSubscribers)
g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats) g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
@ -83,6 +91,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/api/campaigns/:id", handleGetCampaigns) g.GET("/api/campaigns/:id", handleGetCampaigns)
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
g.POST("/api/campaigns/:id/test", handleTestCampaign) g.POST("/api/campaigns/:id/test", handleTestCampaign)
g.POST("/api/campaigns", handleCreateCampaign) g.POST("/api/campaigns", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign) g.PUT("/api/campaigns/:id", handleUpdateCampaign)
@ -117,6 +126,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/settings/logs", handleIndexPage) g.GET("/settings/logs", handleIndexPage)
// Public subscriber facing views. // Public subscriber facing views.
e.GET("/subscription/form", handleSubscriptionFormPage)
e.POST("/subscription/form", handleSubscriptionForm) e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")) "campUUID", "subUUID"))
@ -154,6 +164,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 +201,13 @@ func basicAuth(username, password string, c echo.Context) (bool, error) {
// validateUUID middleware validates the UUID string format for a given set of params. // 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 +227,14 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc
if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil { if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.log.Printf("error checking subscriber existence: %v", err) app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", makeMsgTpl(app.i18n.T("public.errorTitle"), "",
`Error processing request. Please retry.`)) app.i18n.T("public.errorProcessingRequest")))
} }
if !exists { if !exists {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl("Not found", "", makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
`Subscription not found.`)) app.i18n.T("public.subNotFound")))
} }
return next(c) return next(c)
} }

44
cmd/i18n.go Normal file
View File

@ -0,0 +1,44 @@
package main
import (
"encoding/json"
"fmt"
)
type i18nLang struct {
Code string `json:"code"`
Name string `json:"name"`
}
type i18nLangRaw struct {
Code string `json:"_.code"`
Name string `json:"_.name"`
}
// geti18nLangList returns the list of available i18n languages.
func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
list, err := app.fs.Glob("/i18n/*.json")
if err != nil {
return nil, err
}
var out []i18nLang
for _, l := range list {
b, err := app.fs.Get(l)
if err != nil {
return out, fmt.Errorf("error reading lang file: %s: %v", l, err)
}
var lang i18nLangRaw
if err := json.Unmarshal(b.ReadBytes(), &lang); err != nil {
return out, fmt.Errorf("error parsing lang file: %s: %v", l, err)
}
out = append(out, i18nLang{
Code: lang.Code,
Name: lang.Name,
})
}
return out, nil
}

View File

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -27,30 +26,28 @@ func handleImportSubscribers(c echo.Context) error {
// Is an import already running? // Is an import already running?
if app.importer.GetStats().Status == subimporter.StatusImporting { if app.importer.GetStats().Status == subimporter.StatusImporting {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning"))
"An import is already running. Wait for it to finish or stop it before trying again.")
} }
// Unmarsal the JSON params. // Unmarsal the JSON params.
var r reqImport var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil { if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `params` field: %v", err)) app.i18n.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("import.errorProcessingZIP", "error", err.Error()))
} }
go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0])) go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0]))
} }

View File

@ -20,6 +20,7 @@ import (
"github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/media/providers/filesystem" "github.com/knadh/listmonk/internal/media/providers/filesystem"
@ -44,6 +45,9 @@ 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"`
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
Lang string `koanf:"lang"`
DBBatchSize int `koanf:"batch_size"`
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 +135,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 +235,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 +257,36 @@ func initConstants() *constants {
return &c return &c
} }
// initI18n initializes a new i18n instance with the selected language map
// loaded from the filesystem. English is a loaded first as the default map
// and then the selected language is loaded on top of it so that if there are
// missing translations in it, the default English translations show up.
func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
const def = "en"
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
if err != nil {
lo.Fatalf("error reading default i18n language file: %s: %v", def, err)
}
// Initialize with the default language.
i, err := i18n.New(b)
if err != nil {
lo.Fatalf("error unmarshalling i18n language: %v", err)
}
// Load the selected language on top of it.
b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
if err != nil {
lo.Fatalf("error reading i18n language file: %v", err)
}
if err := i.Load(b); err != nil {
lo.Fatalf("error loading i18n language file: %v", err)
}
return i
}
// initCampaignManager initializes the campaign manager. // 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 {
@ -277,7 +313,10 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
ViewTrackURL: cs.ViewTrackURL, ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL, MessageURL: cs.MessageURL,
UnsubHeader: ko.Bool("privacy.unsubscribe_header"), UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
}, newManagerDB(q), campNotifCB, lo) SlidingWindow: ko.Bool("app.message_sliding_window"),
SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
SlidingWindowRate: ko.Int("app.message_sliding_window_rate"),
}, newManagerDB(q), campNotifCB, app.i18n, lo)
} }
@ -407,7 +446,7 @@ func initMediaStore() media.Store {
// initNotifTemplates compiles and returns e-mail notification templates that are // 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.I18n, 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 +454,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.I18n {
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 {
@ -439,7 +482,10 @@ func initHTTPServer(app *App) *echo.Echo {
}) })
// Parse and load user facing templates. // Parse and load user facing templates.
tpl, err := stuffbin.ParseTemplatesGlob(nil, app.fs, "/public/templates/*.html") tpl, err := stuffbin.ParseTemplatesGlob(template.FuncMap{
"L": func() *i18n.I18n {
return app.i18n
}}, app.fs, "/public/templates/*.html")
if err != nil { if err != nil {
lo.Fatalf("error parsing public templates: %v", err) lo.Fatalf("error parsing public templates: %v", err)
} }

View File

@ -96,15 +96,15 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
} }
// Default template. // Default template.
tplBody, err := ioutil.ReadFile("static/email-templates/default.tpl") tplBody, err := fs.Get("/static/email-templates/default.tpl")
if err != nil { if err != nil {
tplBody = []byte(tplTag) lo.Fatalf("error reading default e-mail template: %v", err)
} }
var tplID int var tplID int
if err := q.CreateTemplate.Get(&tplID, if err := q.CreateTemplate.Get(&tplID,
"Default template", "Default template",
string(tplBody), string(tplBody.ReadBytes()),
); err != nil { ); err != nil {
lo.Fatalf("error creating default template: %v", err) lo.Fatalf("error creating default template: %v", err)
} }
@ -120,6 +120,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
"No Reply <noreply@yoursite.com>", "No Reply <noreply@yoursite.com>",
`<h3>Hi {{ .Subscriber.FirstName }}!</h3> `<h3>Hi {{ .Subscriber.FirstName }}!</h3>
This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`, This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`,
nil,
"richtext", "richtext",
nil, nil,
pq.StringArray{"test-campaign"}, pq.StringArray{"test-campaign"},

View File

@ -50,13 +50,15 @@ func handleGetLists(c echo.Context) error {
order = sortAsc order = sortAsc
} }
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching lists: %v", err) 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.Ts("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.Ts("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", "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.Ts("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.Ts("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.Ts("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.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})

View File

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

View File

@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
@ -35,14 +34,14 @@ func handleUploadMedia(c echo.Context) error {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid file uploaded: %v", err)) app.i18n.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
} }
app.media.Delete(m.Filename) app.media.Delete(m.Filename)
@ -160,8 +163,7 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
img, err := imaging.Decode(src) img, err := imaging.Decode(src)
if err != nil { if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, return nil, err
fmt.Sprintf("Error decoding image: %v", err))
} }
// Encode the image into a byte slice as PNG. // Encode the image into a byte slice as PNG.

View File

@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
@ -38,6 +39,7 @@ type tplData struct {
LogoURL string LogoURL string
FaviconURL string FaviconURL string
Data interface{} Data interface{}
L *i18n.I18n
} }
type publicTpl struct { type publicTpl struct {
@ -66,6 +68,11 @@ type msgTpl struct {
Message string Message string
} }
type subFormTpl struct {
publicTpl
Lists []models.List
}
type subForm struct { type subForm struct {
subimporter.SubReq subimporter.SubReq
SubListUUIDs []string `form:"l"` SubListUUIDs []string `form:"l"`
@ -82,6 +89,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 +107,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.Ts("public.errorFetchingCampaign")))
} }
// Get the subscriber. // Get the subscriber.
@ -112,19 +122,22 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil { if err := 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.Ts("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.Ts("public.errorFetchingCampaign")))
} }
// Render the message body. // Render the message body.
@ -132,7 +145,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.Ts("public.errorFetchingCampaign")))
} }
return c.HTML(http.StatusOK, string(m.Body())) return c.HTML(http.StatusOK, string(m.Body()))
@ -151,7 +165,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 +180,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.Ts("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 +203,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 +216,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 +226,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.Ts("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.Ts("public.noSubInfo")))
} }
// Confirm. // Confirm.
@ -228,17 +244,52 @@ 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.Ts("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Confirmed", "", makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "",
`Your subscriptions have been confirmed.`)) app.i18n.Ts("public.subConfirmed")))
} }
return c.Render(http.StatusOK, "optin", out) return c.Render(http.StatusOK, "optin", out)
} }
// handleSubscriptionFormPage handles subscription requests coming from public
// HTML subscription forms.
func handleSubscriptionFormPage(c echo.Context) error {
var (
app = c.Get("app").(*App)
)
if !app.constants.EnablePublicSubPage {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
}
// Get all public lists.
var lists []models.List
if err := app.queries.GetLists.Select(&lists, models.ListTypePublic); err != nil {
app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err))
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingLists")))
}
if len(lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.noListsAvailable")))
}
out := subFormTpl{}
out.Title = app.i18n.T("public.sub")
out.Lists = lists
return c.Render(http.StatusOK, "subscription-form", out)
}
// handleSubscriptionForm handles subscription requests coming from public // handleSubscriptionForm handles subscription requests coming from public
// HTML subscription forms. // HTML subscription forms.
func handleSubscriptionForm(c echo.Context) error { func handleSubscriptionForm(c echo.Context) error {
@ -253,9 +304,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("public.noListsSelected")))
} }
// 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,19 +318,20 @@ 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.
req.Status = models.SubscriberStatusEnabled req.Status = models.SubscriberStatusEnabled
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 && err != errSubscriberExists {
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.subTitle"), "",
app.i18n.Ts("public.subConfirmed")))
} }
// handleLinkRedirect redirects a link UUID to its original underlying link // handleLinkRedirect redirects a link UUID to its original underlying link
@ -302,12 +354,14 @@ func handleLinkRedirect(c echo.Context) error {
if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { if 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.Ts("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.Ts("public.errorProcessingRequest")))
} }
return c.Redirect(http.StatusTemporaryRedirect, url) return c.Redirect(http.StatusTemporaryRedirect, url)
@ -352,7 +406,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.Ts("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 +417,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.Ts("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.Ts("public.errorProcessingRequest")))
} }
// Send the data as a JSON attachment to the subscriber. // Send the data as a JSON attachment to the subscriber.
@ -393,12 +447,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.Ts("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 +468,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.Ts("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.Ts("public.errorProcessingRequest")))
} }
return c.Render(http.StatusOK, tplMessage, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Data removed", "", makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
`Your subscriptions and all associated data has been removed.`)) app.i18n.T("public.dataRemoved")))
} }
// drawTransparentImage draws a transparent PNG of given dimensions // drawTransparentImage draws a transparent PNG of given dimensions

View File

@ -35,6 +35,7 @@ type Queries struct {
// Non-prepared arbitrary subscriber queries. // Non-prepared arbitrary subscriber queries.
QuerySubscribers string `query:"query-subscribers"` QuerySubscribers string `query:"query-subscribers"`
QuerySubscribersForExport string `query:"query-subscribers-for-export"`
QuerySubscribersTpl string `query:"query-subscribers-template"` QuerySubscribersTpl string `query:"query-subscribers-template"`
DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"` DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"`
AddSubscribersToListsByQuery string `query:"add-subscribers-to-lists-by-query"` AddSubscribersToListsByQuery string `query:"add-subscribers-to-lists-by-query"`
@ -43,7 +44,8 @@ type Queries struct {
UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"`
CreateList *sqlx.Stmt `query:"create-list"` CreateList *sqlx.Stmt `query:"create-list"`
GetLists string `query:"get-lists"` QueryLists string `query:"query-lists"`
GetLists *sqlx.Stmt `query:"get-lists"`
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
UpdateList *sqlx.Stmt `query:"update-list"` UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`

View File

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@ -20,11 +19,18 @@ type settings struct {
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"`
EnablePublicSubPage bool `json:"app.enable_public_subscription_page"`
AppLang string `json:"app.lang"`
AppBatchSize int `json:"app.batch_size"` AppBatchSize int `json:"app.batch_size"`
AppConcurrency int `json:"app.concurrency"` AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"` AppMaxSendErrors int `json:"app.max_send_errors"`
AppMessageRate int `json:"app.message_rate"` AppMessageRate int `json:"app.message_rate"`
AppMessageSlidingWindow bool `json:"app.message_sliding_window"`
AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"`
AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"`
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
@ -144,8 +150,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 +174,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.Ts("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 +193,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.Ts("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.Ts("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 +238,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.Ts("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.Ts("settings.errorEncoding", "error", err.Error()))
} }
return out, nil return out, nil

View File

@ -3,7 +3,9 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/csv"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -66,6 +68,8 @@ var (
} }
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"} subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
errSubscriberExists = errors.New("subscriber already exists")
) )
// handleGetSubscriber handles the retrieval of a single subscriber by ID. // handleGetSubscriber handles the retrieval of a single subscriber by ID.
@ -101,7 +105,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 +130,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.Ts("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.Ts("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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} }
out.Query = query out.Query = query
@ -158,6 +164,98 @@ func handleQuerySubscribers(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out}) return c.JSON(http.StatusOK, okResp{out})
} }
// handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression.
func handleExportSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
// Limit the subscribers to a particular list?
listID, _ = strconv.Atoi(c.FormValue("list_id"))
// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
)
listIDs := pq.Int64Array{}
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
} else if listID > 0 {
listIDs = append(listIDs, int64(listID))
}
// There's an arbitrary query condition.
cond := ""
if query != "" {
cond = " AND " + query
}
stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond)
// Verify that the arbitrary SQL search expression is read only.
if cond != "" {
tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
if _, err := tx.Query(stmt, nil, 0, 1); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
}
// Prepare the actual query statement.
tx, err := db.Preparex(stmt)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
// Run the query until all rows are exhausted.
var (
id = 0
h = c.Response().Header()
wr = csv.NewWriter(c.Response())
)
h.Set(echo.HeaderContentType, echo.MIMEOctetStream)
h.Set("Content-type", "text/csv")
h.Set(echo.HeaderContentDisposition, "attachment; filename="+"subscribers.csv")
h.Set("Content-Transfer-Encoding", "binary")
h.Set("Cache-Control", "no-cache")
wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"})
loop:
for {
var out []models.SubscriberExport
if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
break loop
}
for _, r := range out {
if err = wr.Write([]string{r.UUID, r.Email, r.Name, r.Attribs, r.Status,
r.CreatedAt.Time.String(), r.UpdatedAt.Time.String()}); err != nil {
app.log.Printf("error streaming CSV export: %v", err)
break loop
}
}
wr.Flush()
id = out[len(out)-1].ID
}
return nil
}
// handleCreateSubscriber handles the creation of a new subscriber. // handleCreateSubscriber handles the creation of a new subscriber.
func handleCreateSubscriber(c echo.Context) error { func handleCreateSubscriber(c echo.Context) error {
var ( var (
@ -177,6 +275,10 @@ func handleCreateSubscriber(c echo.Context) error {
// Insert the subscriber into the DB. // Insert the subscriber into the DB.
sub, err := insertSubscriber(req, app) sub, err := insertSubscriber(req, app)
if err != nil { if err != nil {
if err == errSubscriberExists {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return err return err
} }
@ -196,13 +298,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 +316,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.Ts("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 +339,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 +347,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.Ts("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.Ts("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 +376,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 +384,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.Ts("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 +396,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.Ts("subscribers.errorBlocklisting", "error", err.Error()))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -311,7 +416,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 +424,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.Ts("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 +446,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.Ts("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 +472,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 +480,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.Ts("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.Ts("subscribers.errorNoIDs", "error", err.Error()))
} }
IDs = i IDs = i
} }
@ -387,7 +492,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.Ts("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 +515,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.Ts("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 +541,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.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
} }
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
@ -453,7 +560,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 +574,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.Ts("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 +600,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 +609,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.Ts("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 +637,13 @@ 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, errSubscriberExists
} }
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.Ts("globals.messages.errorCreating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
} }
// Fetch the subscriber's full data. // Fetch the subscriber's full data.
@ -553,21 +664,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.Ts("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.Ts("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.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
} }
return out[0], nil return out[0], nil
@ -647,8 +762,8 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
// Send the e-mail. // Send the e-mail.
if err := app.sendNotification([]string{sub.Email}, if err := app.sendNotification([]string{sub.Email},
"Confirm subscription", notifSubscriberOptin, out); err != nil { app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err) app.log.Printf("error sending opt-in e-mail: %s", err)
return err return err
} }
return nil return nil

View File

@ -50,16 +50,17 @@ func handleGetTemplates(c echo.Context) error {
err := app.queries.GetTemplates.Select(&out, id, noBody) err := app.queries.GetTemplates.Select(&out, id, noBody)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err))) app.i18n.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("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.Ts("templates.placeholderHelp", "placeholder", tplTag))
} }
return nil return nil

View File

@ -28,6 +28,7 @@ var migList = []migFunc{
{"v0.4.0", migrations.V0_4_0}, {"v0.4.0", migrations.V0_4_0},
{"v0.7.0", migrations.V0_7_0}, {"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0}, {"v0.8.0", migrations.V0_8_0},
{"v0.9.0", migrations.V0_9_0},
} }
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files

View File

@ -12,9 +12,6 @@ import (
) )
var ( var (
// This replaces all special characters
tagRegexp = regexp.MustCompile(`[^a-z0-9\-\s]`)
tagRegexpSpaces = regexp.MustCompile(`[\s]+`) tagRegexpSpaces = regexp.MustCompile(`[\s]+`)
) )
@ -63,13 +60,11 @@ func pqErrMsg(err error) string {
func normalizeTags(tags []string) []string { func normalizeTags(tags []string) []string {
var ( var (
out []string out []string
space = []byte(" ")
dash = []byte("-") dash = []byte("-")
) )
for _, t := range tags { for _, t := range tags {
rep := bytes.TrimSpace(tagRegexp.ReplaceAll(bytes.ToLower([]byte(t)), space)) rep := tagRegexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
rep = tagRegexpSpaces.ReplaceAll(rep, dash)
if len(rep) > 0 { if len(rep) > 0 {
out = append(out, string(rep)) out = append(out, string(rep))

View File

@ -24,8 +24,10 @@
"quill": "^1.3.7", "quill": "^1.3.7",
"quill-delta": "^4.2.2", "quill-delta": "^4.2.2",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"textversionjs": "^1.1.3",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-c3": "^1.2.11", "vue-c3": "^1.2.11",
"vue-i18n": "^8.22.2",
"vue-quill-editor": "^3.0.6", "vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0", "vue-router": "^3.2.0",
"vuex": "^3.4.0" "vuex": "^3.4.0"

View File

@ -28,68 +28,68 @@
<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"
icon="newspaper-variant-outline" label="Forms"></b-menu-item> icon="newspaper-variant-outline" :label="$t('menu.forms')"></b-menu-item>
</b-menu-item><!-- lists --> </b-menu-item><!-- lists -->
<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"
icon="file-upload-outline" label="Import"></b-menu-item> icon="file-upload-outline" :label="$t('menu.import')"></b-menu-item>
</b-menu-item><!-- subscribers --> </b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns" <b-menu-item :expanded="activeGroup.campaigns"
:active="activeGroup.campaigns" :active="activeGroup.campaigns"
v-on:update:active="(state) => toggleGroup('campaigns', state)" v-on:update:active="(state) => toggleGroup('campaigns', state)"
icon="rocket-launch-outline" label="Campaigns"> icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link" <b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns" :active="activeItem.campaigns"
icon="rocket-launch-outline" label="All campaigns"></b-menu-item> icon="rocket-launch-outline" :label="$t('menu.allCampaigns')"></b-menu-item>
<b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link" <b-menu-item :to="{name: 'campaign', params: {id: 'new'}}" tag="router-link"
:active="activeItem.campaign" :active="activeItem.campaign"
icon="plus" label="Create new"></b-menu-item> icon="plus" :label="$t('menu.newCampaign')"></b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link" <b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media" :active="activeItem.media"
icon="image-outline" label="Media"></b-menu-item> icon="image-outline" :label="$t('menu.media')"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link" <b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates" :active="activeItem.templates"
icon="file-image-outline" label="Templates"></b-menu-item> icon="file-image-outline" :label="$t('globals.terms.templates')"></b-menu-item>
</b-menu-item><!-- campaigns --> </b-menu-item><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings" <b-menu-item :expanded="activeGroup.settings"
:active="activeGroup.settings" :active="activeGroup.settings"
v-on:update:active="(state) => toggleGroup('settings', state)" v-on:update:active="(state) => toggleGroup('settings', state)"
icon="cog-outline" label="Settings"> icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{name: 'settings'}" tag="router-link" <b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings" :active="activeItem.settings"
icon="cog-outline" label="Settings"></b-menu-item> icon="cog-outline" :label="$t('menu.settings')"></b-menu-item>
<b-menu-item :to="{name: 'logs'}" tag="router-link" <b-menu-item :to="{name: 'logs'}" tag="router-link"
:active="activeItem.logs" :active="activeItem.logs"
icon="newspaper-variant-outline" label="Logs"></b-menu-item> icon="newspaper-variant-outline" :label="$t('menu.logs')"></b-menu-item>
</b-menu-item><!-- settings --> </b-menu-item><!-- settings -->
</b-menu-list> </b-menu-list>
</b-menu> </b-menu>

View File

@ -224,6 +224,16 @@ section {
} }
} }
.editor {
margin-bottom: 30px;
}
.plain-editor textarea {
height: 65vh;
}
.alt-body textarea {
height: 30vh;
}
/* Table colors and padding */ /* Table colors and padding */
.main table { .main table {
thead th { thead th {

View File

@ -7,19 +7,19 @@
<div> <div>
<b-radio v-model="form.radioFormat" <b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format" @input="onChangeFormat" :disabled="disabled" name="format"
native-value="richtext">Rich text</b-radio> native-value="richtext">{{ $t('campaigns.richText') }}</b-radio>
<b-radio v-model="form.radioFormat" <b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format" @input="onChangeFormat" :disabled="disabled" name="format"
native-value="html">Raw HTML</b-radio> native-value="html">{{ $t('campaigns.rawHTML') }}</b-radio>
<b-radio v-model="form.radioFormat" <b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format" @input="onChangeFormat" :disabled="disabled" name="format"
native-value="plain">Plain text</b-radio> native-value="plain">{{ $t('campaigns.plainText') }}</b-radio>
</div> </div>
</b-field> </b-field>
</div> </div>
<div class="column is-6 has-text-right"> <div class="column is-6 has-text-right">
<b-button @click="onTogglePreview" type="is-primary" <b-button @click="onTogglePreview" type="is-primary"
icon-left="file-find-outline">Preview</b-button> icon-left="file-find-outline">{{ $t('campaigns.preview') }}</b-button>
</div> </div>
</div> </div>
@ -31,7 +31,7 @@
ref="quill" ref="quill"
:options="options" :options="options"
:disabled="disabled" :disabled="disabled"
placeholder="Content here" :placeholder="$t('campaigns.contentHelp')"
@change="onEditorChange($event)" @change="onEditorChange($event)"
@ready="onEditorReady($event)" @ready="onEditorReady($event)"
/> />
@ -142,7 +142,7 @@ export default {
// Quill editor options. // Quill editor options.
options: { options: {
placeholder: 'Content here', placeholder: this.$t('campaigns.contentHelp'),
modules: { modules: {
keyboard: { keyboard: {
bindings: { bindings: {
@ -188,7 +188,7 @@ export default {
methods: { methods: {
onChangeFormat(format) { onChangeFormat(format) {
this.$utils.confirm( this.$utils.confirm(
'The content may lose some formatting. Are you sure?', this.$t('campaigns.confirmSwitchFormat'),
() => { () => {
this.form.format = format; this.form.format = format;
this.onEditorChange(); this.onEditorChange();
@ -244,6 +244,8 @@ export default {
this.form.body = b; this.form.body = b;
this.$emit('input', { contentType: this.form.format, body: this.form.body }); this.$emit('input', { contentType: this.form.format, body: this.form.body });
}); });
this.isReady = true;
}, },
onTogglePreview() { onTogglePreview() {
@ -288,6 +290,7 @@ export default {
body(b) { body(b) {
this.form.body = b; this.form.body = b;
this.onEditorChange();
}, },
htmlFormat(f) { htmlFormat(f) {

View File

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

View File

@ -18,6 +18,7 @@ export const uris = Object.freeze({
previewCampaign: '/api/campaigns/:id/preview', previewCampaign: '/api/campaigns/:id/preview',
previewTemplate: '/api/templates/:id/preview', previewTemplate: '/api/templates/:id/preview',
previewRawTemplate: '/api/templates/preview', previewRawTemplate: '/api/templates/preview',
exportSubscribers: '/api/subscribers/export',
}); });
// Keys used in Vuex store. // Keys used in Vuex store.

View File

@ -1,22 +1,32 @@
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';
import store from './store'; import store from './store';
import * as api from './api'; import * as api from './api';
import utils from './utils';
import { models } from './constants'; import { models } from './constants';
import Utils from './utils';
// Internationalisation.
Vue.use(VueI18n);
const i18n = new VueI18n();
Vue.use(Buefy, {}); Vue.use(Buefy, {});
Vue.config.productionTip = false; Vue.config.productionTip = false;
// Custom global elements. // Globals.
Vue.prototype.$api = api; const ut = new Utils(i18n);
Vue.prototype.$utils = utils; Vue.mixin({
computed: {
$utils: () => ut,
$api: () => api,
},
Vue.prototype.$reloadServerConfig = () => { methods: {
$reloadServerConfig: () => {
// Get the config.js <script> tag, remove it, and re-add it. // Get the config.js <script> tag, remove it, and re-add it.
let s = document.querySelector('#server-config'); let s = document.querySelector('#server-config');
const url = s.getAttribute('src'); const url = s.getAttribute('src');
@ -30,16 +40,24 @@ Vue.prototype.$reloadServerConfig = () => {
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) }); { model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
}; };
document.body.appendChild(s); document.body.appendChild(s);
}; },
},
});
// window.CONFIG is loaded from /api/config.js directly in a <script> tag. // window.CONFIG is loaded from /api/config.js directly in a <script> tag.
if (window.CONFIG) { if (window.CONFIG) {
store.commit('setModelResponse', store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) }); { model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
// Load language.
i18n.locale = window.CONFIG.lang['_.code'];
i18n.setLocaleMessage(i18n.locale, window.CONFIG.lang);
} }
new Vue({ new Vue({
router, router,
store, store,
i18n,
render: (h) => h(App), render: (h) => h(App),
}).$mount('#app'); }).$mount('#app');

View File

@ -9,21 +9,22 @@ dayjs.extend(relativeTime);
const reEmail = /(.+?)@(.+?)/ig; const reEmail = /(.+?)@(.+?)/ig;
export default class utils { export default class Utils {
static months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', constructor(i18n) {
'Sep', 'Oct', 'Nov', 'Dec']; this.i18n = i18n;
}
static days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Parses an ISO timestamp to a simpler form. // Parses an ISO timestamp to a simpler form.
static niceDate = (stamp, showTime) => { niceDate = (stamp, showTime) => {
if (!stamp) { if (!stamp) {
return ''; return '';
} }
const d = new Date(stamp); const d = new Date(stamp);
let out = `${utils.days[d.getDay()]}, ${d.getDate()}`; const day = this.i18n.t(`globals.days.${(d.getDay() + 1)}`);
out += ` ${utils.months[d.getMonth()]} ${d.getFullYear()}`; const month = this.i18n.t(`globals.months.${(d.getMonth() + 1)}`);
let out = `${day}, ${d.getDate()}`;
out += ` ${month} ${d.getFullYear()}`;
if (showTime) { if (showTime) {
out += ` ${d.getHours()}:${d.getMinutes()}`; out += ` ${d.getHours()}:${d.getMinutes()}`;
} }
@ -31,14 +32,12 @@ export default class utils {
return out; return out;
}; };
static duration(start, end) { duration = (start, end) => dayjs(end).from(dayjs(start), true);
return dayjs(end).from(dayjs(start), true);
}
// Simple, naive, e-mail address check. // Simple, naive, e-mail address check.
static validateEmail = (e) => e.match(reEmail); validateEmail = (e) => e.match(reEmail);
static niceNumber = (n) => { niceNumber = (n) => {
if (n === null || n === undefined) { if (n === null || n === undefined) {
return 0; return 0;
} }
@ -69,20 +68,23 @@ export default class utils {
} }
// UI shortcuts. // UI shortcuts.
static confirm = (msg, onConfirm, onCancel) => { confirm = (msg, onConfirm, onCancel) => {
Dialog.confirm({ Dialog.confirm({
scroll: 'clip', scroll: 'clip',
message: !msg ? 'Are you sure?' : msg, message: !msg ? this.i18n.t('globals.messages.confirm') : msg,
confirmText: this.i18n.t('globals.buttons.ok'),
cancelText: this.i18n.t('globals.buttons.cancel'),
onConfirm, onConfirm,
onCancel, onCancel,
}); });
}; };
static prompt = (msg, inputAttrs, onConfirm, onCancel) => { prompt = (msg, inputAttrs, onConfirm, onCancel) => {
Dialog.prompt({ Dialog.prompt({
scroll: 'clip', scroll: 'clip',
message: msg, message: msg,
confirmText: 'OK', confirmText: this.i18n.t('globals.buttons.ok'),
cancelText: this.i18n.t('globals.buttons.cancel'),
inputAttrs: { inputAttrs: {
type: 'string', type: 'string',
maxlength: 200, maxlength: 200,
@ -94,7 +96,7 @@ export default class utils {
}); });
}; };
static toast = (msg, typ, duration) => { toast = (msg, typ, duration) => {
Toast.open({ Toast.open({
message: msg, message: msg,
type: !typ ? 'is-success' : typ, type: !typ ? 'is-success' : typ,

View File

@ -6,25 +6,27 @@
<b-tag v-if="isEditing" :class="data.status">{{ data.status }}</b-tag> <b-tag v-if="isEditing" :class="data.status">{{ data.status }}</b-tag>
<b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag> <b-tag v-if="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag>
<span v-if="isEditing" class="has-text-grey-light is-size-7"> <span v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }} {{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</span> </span>
</p> </p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4> <h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
<h4 v-else class="title is-4">New campaign</h4> <h4 v-else class="title is-4">{{ $t('campaigns.newCampaign') }}</h4>
</div> </div>
<div class="column"> <div class="column">
<div class="buttons" v-if="isEditing && canEdit"> <div class="buttons" v-if="isEditing && canEdit">
<b-button @click="onSubmit" :loading="loading.campaigns" <b-button @click="onSubmit" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline">Save changes</b-button> type="is-primary" icon-left="content-save-outline">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns" <b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline"> type="is-primary" icon-left="rocket-launch-outline">
Start campaign {{ $t('campaigns.start') }}
</b-button> </b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns" <b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start"> type="is-primary" icon-left="clock-start">
Schedule campaign {{ $t('campaigns.schedule') }}
</b-button> </b-button>
</div> </div>
</div> </div>
@ -33,24 +35,25 @@
<b-loading :active="loading.campaigns"></b-loading> <b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab"> <b-tabs type="is-boxed" :animated="false" v-model="activeTab">
<b-tab-item label="Campaign" label-position="on-border" icon="rocket-launch-outline"> <b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border"
icon="rocket-launch-outline">
<section class="wrap"> <section class="wrap">
<div class="columns"> <div class="columns">
<div class="column is-7"> <div class="column is-7">
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<b-field label="Name" label-position="on-border"> <b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit" <b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
placeholder="Name" required></b-input> :placeholder="$t('globals.fields.name')" required></b-input>
</b-field> </b-field>
<b-field label="Subject" label-position="on-border"> <b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit" <b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
placeholder="Subject" required></b-input> :placeholder="$t('campaigns.subject')" required></b-input>
</b-field> </b-field>
<b-field label="From address" label-position="on-border"> <b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit" <b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
placeholder="Your Name <noreply@yoursite.com>" required></b-input> :placeholder="$t('campaigns.fromAddressPlaceholder')" required></b-input>
</b-field> </b-field>
<list-selector <list-selector
@ -58,35 +61,35 @@
:selected="form.lists" :selected="form.lists"
:all="lists.results" :all="lists.results"
:disabled="!canEdit" :disabled="!canEdit"
label="Lists" :label="$t('globals.terms.lists')"
placeholder="Lists to send to" :placeholder="$t('campaigns.sendToLists')"
></list-selector> ></list-selector>
<b-field label="Template" label-position="on-border"> <b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select placeholder="Template" v-model="form.templateId" <b-select :placeholder="$tc('globals.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('globals.terms.messenger')" label-position="on-border">
<b-select placeholder="Messenger" v-model="form.messenger" <b-select :placeholder="$tc('globals.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-4">
<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('campaigns.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>
@ -133,8 +137,7 @@
</section> </section>
</b-tab-item><!-- campaign --> </b-tab-item><!-- campaign -->
<b-tab-item label="Content" icon="text" :disabled="isNew"> <b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew">
<section class="wrap">
<editor <editor
v-model="form.content" v-model="form.content"
:id="data.id" :id="data.id"
@ -143,7 +146,21 @@
:body="data.body" :body="data.body"
:disabled="!canEdit" :disabled="!canEdit"
/> />
</section>
<div v-if="canEdit && form.content.contentType !== 'plain'" class="alt-body">
<p class="is-size-6 has-text-grey has-text-right">
<a v-if="form.altbody === null" href="#" @click.prevent="addAltBody">
<b-icon icon="text" size="is-small" /> {{ $t('campaigns.addAltText') }}
</a>
<a v-else href="#" @click.prevent="$utils.confirm(null, removeAltBody)">
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('campaigns.removeAltText') }}
</a>
</p>
<br />
<b-input v-if="form.altbody !== null" v-model="form.altbody"
type="textarea" :disabled="!canEdit" />
</div>
</b-tab-item><!-- content --> </b-tab-item><!-- content -->
</b-tabs> </b-tabs>
</section> </section>
@ -153,6 +170,8 @@
import Vue from 'vue'; import Vue from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import htmlToPlainText from 'textversionjs';
import ListSelector from '../components/ListSelector.vue'; import ListSelector from '../components/ListSelector.vue';
import Editor from '../components/Editor.vue'; import Editor from '../components/Editor.vue';
@ -183,6 +202,7 @@ export default Vue.extend({
tags: [], tags: [],
sendAt: null, sendAt: null,
content: { contentType: 'richtext', body: '' }, content: { contentType: 'richtext', body: '' },
altbody: null,
// Parsed Date() version of send_at from the API. // Parsed Date() version of send_at from the API.
sendAtDate: null, sendAtDate: null,
@ -198,6 +218,22 @@ export default Vue.extend({
return dayjs(s).format('YYYY-MM-DD HH:mm'); return dayjs(s).format('YYYY-MM-DD HH:mm');
}, },
addAltBody() {
this.form.altbody = htmlToPlainText(this.form.content.body);
},
removeAltBody() {
this.form.altbody = null;
},
onSubmit() {
if (this.isNew) {
this.createCampaign();
} else {
this.updateCampaign();
}
},
getCampaign(id) { getCampaign(id) {
return this.$api.getCampaign(id).then((data) => { return this.$api.getCampaign(id).then((data) => {
this.data = data; this.data = data;
@ -229,23 +265,16 @@ export default Vue.extend({
template_id: this.form.templateId, template_id: this.form.templateId,
content_type: this.form.content.contentType, content_type: this.form.content.contentType,
body: this.form.content.body, body: this.form.content.body,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
subscribers: this.form.testEmails, subscribers: this.form.testEmails,
}; };
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;
}, },
onSubmit() {
if (this.isNew) {
this.createCampaign();
} else {
this.updateCampaign();
}
},
createCampaign() { createCampaign() {
const data = { const data = {
name: this.form.name, name: this.form.name,
@ -280,18 +309,19 @@ export default Vue.extend({
template_id: this.form.templateId, template_id: this.form.templateId,
content_type: this.form.content.contentType, content_type: this.form.content.contentType,
body: this.form.content.body, body: this.form.content.body,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
}; };
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 +403,7 @@ export default Vue.extend({
} else { } else {
const intID = parseInt(id, 10); const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) { if (intID <= 0 || Number.isNaN(intID)) {
this.$utils.toast('Invalid campaign'); this.$utils.toast(this.$t('campaigns.invalid'));
return; return;
} }

View File

@ -2,20 +2,20 @@
<section class="campaigns"> <section class="campaigns">
<header class="columns"> <header class="columns">
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<h1 class="title is-4">Campaigns <h1 class="title is-4">{{ $t('globals.terms.campaigns') }}
<span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span> <span v-if="!isNaN(campaigns.total)">({{ campaigns.total }})</span>
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link" <b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link"
type="is-primary" icon-left="plus">New</b-button> type="is-primary" icon-left="plus">{{ $t('globals.buttons.new') }}</b-button>
</div> </div>
</header> </header>
<form @submit.prevent="getCampaigns"> <form @submit.prevent="getCampaigns">
<b-field grouped> <b-field grouped>
<b-input v-model="queryParams.query" <b-input v-model="queryParams.query"
placeholder="Name or subject" icon="magnify" ref="query"></b-input> :placeholder="$t('campaigns.queryPlaceholder')" icon="magnify" ref="query"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify"></b-button> <b-button native-type="submit" type="is-primary" icon-left="magnify"></b-button>
</b-field> </b-field>
</form> </form>
@ -28,19 +28,21 @@
:current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total" :current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total"
hoverable backend-sorting @sort="onSort"> hoverable backend-sorting @sort="onSort">
<template slot-scope="props"> <template slot-scope="props">
<b-table-column class="status" field="status" label="Status" <b-table-column class="status" field="status" :label="$t('globals.fields.status')"
width="10%" :id="props.row.id" sortable> width="10%" :id="props.row.id" sortable>
<div> <div>
<p> <p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}"> <router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag> <b-tag :class="props.row.status">
{{ $t(`campaigns.status.${props.row.status}`) }}
</b-tag>
<span class="spinner is-tiny" v-if="isRunning(props.row.id)"> <span class="spinner is-tiny" v-if="isRunning(props.row.id)">
<b-loading :is-full-page="false" active /> <b-loading :is-full-page="false" active />
</span> </span>
</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 +52,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 +67,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,18 +77,19 @@
</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>{{ $t('globals.fields.createdAt') }}</label>
{{ $utils.niceDate(props.row.createdAt, true) }} {{ $utils.niceDate(props.row.createdAt, true) }}
</p> </p>
<p v-if="stats.startedAt"> <p v-if="stats.startedAt">
<label>Started</label> <label>{{ $t('campaigns.startedAt') }}</label>
{{ $utils.niceDate(stats.startedAt, true) }} {{ $utils.niceDate(stats.startedAt, true) }}
</p> </p>
<p v-if="isDone(props.row)"> <p v-if="isDone(props.row)">
<label>Ended</label> <label>{{ $t('campaigns.ended') }}</label>
{{ $utils.niceDate(stats.updatedAt, true) }} {{ $utils.niceDate(stats.updatedAt, true) }}
</p> </p>
<p v-if="stats.startedAt && stats.updatedAt" <p v-if="stats.startedAt && stats.updatedAt"
@ -96,18 +100,19 @@
</div> </div>
</b-table-column> </b-table-column>
<b-table-column field="stats" :class="props.row.status" label="Stats" width="18%"> <b-table-column field="stats" :class="props.row.status"
:label="$t('campaigns.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 +122,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 +137,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="Schedule" type="is-dark"> <b-tooltip :label="$t('campaigns.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'),
value: $t('campaigns.copyOf', { name: props.row.name }) },
(name) => cloneCampaign(name, props.row))"> (name) => cloneCampaign(name, props.row))">
<b-tooltip label="Clone" type="is-dark"> <b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" /> <b-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 +336,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();
}); });
@ -349,6 +354,7 @@ export default Vue.extend({
tags: c.tags, tags: c.tags,
template_id: c.templateId, template_id: c.templateId,
body: c.body, body: c.body,
altbody: c.altbody,
}; };
this.$api.createCampaign(data).then((d) => { this.$api.createCampaign(data).then((d) => {
this.$router.push({ name: 'campaign', params: { id: d.id } }); this.$router.push({ name: 'campaign', params: { id: d.id } });
@ -358,7 +364,7 @@ export default Vue.extend({
deleteCampaign(c) { deleteCampaign(c) {
this.$api.deleteCampaign(c.id).then(() => { this.$api.deleteCampaign(c.id).then(() => {
this.getCampaigns(); this.getCampaigns();
this.$utils.toast(`'${c.name}' deleted`); this.$utils.toast(this.$t('globals.messages.deleted', { name: c.name }));
}); });
}, },
}, },

View File

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

View File

@ -1,12 +1,18 @@
<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" />
<p v-else-if="publicLists.length === 0">
{{ $t('forms.noPublicLists') }}
</p>
<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">
@ -15,34 +21,41 @@
:native-value="l.uuid">{{ l.name }}</b-checkbox> :native-value="l.uuid">{{ l.name }}</b-checkbox>
</li> </li>
</ul> </ul>
<template v-if="serverConfig.enablePublicSubscriptionPage">
<hr />
<h4>{{ $t('forms.publicSubPage') }}</h4>
<p>
<a :href="`${serverConfig.rootURL}/subscription/form`"
target="_blank">{{ serverConfig.rootURL }}/subscription/form</a>
</p>
</template>
</div> </div>
<div class="column"> <div class="column">
<h4>Form HTML</h4> <h4>{{ $t('forms.formHTML') }}</h4>
<p> <p>
Use the following HTML to show a subscription form on an external webpage. {{ $t('forms.formHTMLHelp') }}
</p>
<p>
The form should have the <code>email</code> field and one or more <code>l</code>
(list UUID) fields. The <code>name</code> field is optional.
</p> </p>
<pre><!-- eslint-disable max-len -->&lt;form method=&quot;post&quot; action=&quot;http://localhost:9000/subscription/form&quot; class=&quot;listmonk-form&quot;&gt; <!-- eslint-disable max-len -->
<pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ serverConfig.rootURL }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
&lt;div&gt; &lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt; &lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;E-mail&quot; /&gt;&lt;/p&gt; &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;Name (optional)&quot; /&gt;&lt;/p&gt; &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;{{ $t('public.subName') }}&quot; /&gt;&lt;/p&gt;
<template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)"> <template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)">
&lt;p&gt; &lt;p&gt;
&lt;input id=&quot;{{ id }}&quot; type=&quot;checkbox&quot; name=&quot;l&quot; value=&quot;{{ l.uuid }}&quot; /&gt; &lt;input id=&quot;{{ id }}&quot; type=&quot;checkbox&quot; name=&quot;l&quot; checked value=&quot;{{ l.uuid }}&quot; /&gt;
&lt;label for=&quot;{{ id }}&quot;&gt;{{ l.name }}&lt;/label&gt; &lt;label for=&quot;{{ id }}&quot;&gt;{{ l.name }}&lt;/label&gt;
&lt;/p&gt;</span></template> &lt;/p&gt;</span></template>
&lt;p&gt;&lt;input type=&quot;submit&quot; value=&quot;Subscribe&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;input type=&quot;submit&quot; value=&quot;{{ $t('public.sub') }}&quot; /&gt;&lt;/p&gt;
&lt;/div&gt; &lt;/div&gt;
&lt;/form&gt;</pre> &lt;/form&gt;</pre>
</div> </div>
</div><!-- columns --> </div><!-- columns -->
<p v-else>There are no public lists to create forms.</p>
</section> </section>
</template> </template>
@ -66,7 +79,7 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['lists', 'loading']), ...mapState(['lists', 'loading', 'serverConfig']),
publicLists() { publicLists() {
if (!this.lists.results) { if (!this.lists.results) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,12 @@
<b-loading :is-full-page="true" v-if="isLoading" active /> <b-loading :is-full-page="true" v-if="isLoading" active />
<header class="columns"> <header class="columns">
<div class="column is-half"> <div class="column is-half">
<h1 class="title is-4">Settings</h1> <h1 class="title is-4">{{ $t('settings.title') }}</h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-button :disabled="!hasFormChanged" <b-button :disabled="!hasFormChanged"
type="is-primary" icon-left="content-save-outline" type="is-primary" icon-left="content-save-outline"
@click="onSubmit" class="isSaveEnabled">Save changes</b-button> @click="onSubmit" class="isSaveEnabled">{{ $t('globals.buttons.save') }}</b-button>
</div> </div>
</header> </header>
<hr /> <hr />
@ -16,137 +16,167 @@
<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>
<b-field :label="$t('settings.general.enablePublicSubPage')"
:message="$t('settings.general.enablePublicSubPageHelp')">
<b-switch v-model="form['app.enable_public_subscription_page']"
name="app.enable_public_subscription_page" />
</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" />
</b-field> </b-field>
<div>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('settings.performance.slidingWindow')"
:message="$t('settings.performance.slidingWindowHelp')">
<b-switch v-model="form['app.message_sliding_window']"
name="app.message_sliding_window" />
</b-field>
</div>
<div class="column is-3"
:class="{'disabled': !form['app.message_sliding_window']}">
<b-field :label="$t('settings.performance.slidingWindowRate')"
label-position="on-border"
:message="$t('settings.performance.slidingWindowRateHelp')">
<b-numberinput v-model="form['app.message_sliding_window_rate']"
name="sliding_window_rate" type="is-light"
controls-position="compact"
:disabled="!form['app.message_sliding_window']"
placeholder="25" min="1" max="10000000" />
</b-field>
</div>
<div class="column is-3"
:class="{'disabled': !form['app.message_sliding_window']}">
<b-field :label="$t('settings.performance.slidingWindowDuration')"
label-position="on-border"
:message="$t('settings.performance.slidingWindowDurationHelp')">
<b-input v-model="form['app.message_sliding_window_duration']"
name="sliding_window_duration"
:disabled="!form['app.message_sliding_window']"
placeholder="1h" :pattern="regDuration" :maxlength="10" />
</b-field>
</div>
</div>
</div><!-- sliding window -->
</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.allowBlocklistHelp')">
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>
</div> </div>
</b-tab-item><!-- privacy --> </b-tab-item><!-- privacy -->
<b-tab-item label="Media uploads"> <b-tab-item :label="$t('settings.media.title')">
<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 +184,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 +201,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 +210,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 +228,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 +258,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 +271,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 +292,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 +310,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 +322,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 +344,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.skipTLS')" expanded
message="Skip hostname check on the TLS certificate."> :message="$t('settings.smtp.skipTLSHelp')">
<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 +368,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 +385,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 +404,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 +418,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 +444,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 +462,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 +481,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 +500,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 +514,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>
@ -518,6 +564,11 @@ export default Vue.extend({
tls_enabled: true, tls_enabled: true,
tls_skip_verify: false, tls_skip_verify: false,
}); });
this.$nextTick(() => {
const items = document.querySelectorAll('.mail-servers input[name="host"]');
items[items.length - 1].focus();
});
}, },
removeSMTP(i) { removeSMTP(i) {
@ -541,6 +592,11 @@ export default Vue.extend({
max_msg_retries: 2, max_msg_retries: 2,
timeout: '5s', timeout: '5s',
}); });
this.$nextTick(() => {
const items = document.querySelectorAll('.messengers input[name="name"]');
items[items.length - 1].focus();
});
}, },
removeMessenger(i) { removeMessenger(i) {
@ -587,7 +643,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 +701,7 @@ export default Vue.extend({
beforeRouteLeave(to, from, next) { beforeRouteLeave(to, from, next) {
if (this.hasFormChanged) { if (this.hasFormChanged) {
this.$utils.confirm('Discard changes?', () => next(true)); this.$utils.confirm(this.$t('settings.messengers.messageDiscard'), () => next(true));
return; return;
} }
next(true); next(true);

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<section class="subscribers"> <section class="subscribers">
<header class="columns"> <header class="columns">
<div class="column is-half"> <div class="column is-half">
<h1 class="title is-4">Subscribers <h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">({{ subscribers.total }})</span> <span v-if="!isNaN(subscribers.total)">({{ subscribers.total }})</span>
<span v-if="currentList"> <span v-if="currentList">
&raquo; {{ currentList.name }} &raquo; {{ currentList.name }}
@ -10,7 +10,9 @@
</h1> </h1>
</div> </div>
<div class="column has-text-right"> <div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button> <b-button type="is-primary" icon-left="plus" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</div> </div>
</header> </header>
@ -20,7 +22,7 @@
<div> <div>
<b-field grouped> <b-field grouped>
<b-input @input="onSimpleQueryInput" v-model="queryInput" <b-input @input="onSimpleQueryInput" v-model="queryInput"
placeholder="E-mail or name" icon="magnify" ref="query" :placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced"></b-input> :disabled="isSearchAdvanced"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify" <b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="isSearchAdvanced"></b-button> :disabled="isSearchAdvanced"></b-button>
@ -28,7 +30,9 @@
<p> <p>
<a href="#" @click.prevent="toggleAdvancedSearch"> <a href="#" @click.prevent="toggleAdvancedSearch">
<b-icon icon="cog-outline" size="is-small" /> Advanced</a> <b-icon icon="cog-outline" size="is-small" />
{{ $t('subscribers.advancedQuery') }}
</a>
</p> </p>
<div v-if="isSearchAdvanced"> <div v-if="isSearchAdvanced">
@ -41,17 +45,20 @@
</b-field> </b-field>
<b-field> <b-field>
<span class="is-size-6 has-text-grey"> <span class="is-size-6 has-text-grey">
Partial SQL expression to query subscriber attributes.{{ ' ' }} {{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation" <a href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank" rel="noopener noreferrer"> Learn more. target="_blank" rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}.
</a> </a>
</span> </span>
</b-field> </b-field>
<div class="buttons"> <div class="buttons">
<b-button native-type="submit" type="is-primary" <b-button native-type="submit" type="is-primary"
icon-left="magnify">Query</b-button> icon-left="magnify">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">Reset</b-button> <b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">
{{ $t('subscribers.reset') }}
</b-button>
</div> </div>
</div><!-- advanced query --> </div><!-- advanced query -->
</div> </div>
@ -62,11 +69,13 @@
<div> <div>
<p> <p>
<span class="is-size-5 has-text-weight-semibold"> <span class="is-size-5 has-text-weight-semibold">
{{ numSelectedSubscribers }} subscriber(s) selected {{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
</span> </span>
<span v-if="!bulk.all && subscribers.total > subscribers.perPage"> <span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash; <a href="" @click.prevent="selectAllSubscribers"> &mdash;
Select all {{ subscribers.total }}</a> <a href="" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
</a>
</span> </span>
</p> </p>
@ -95,15 +104,22 @@
paginated backend-pagination pagination-position="both" @page-change="onPageChange" paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total" :current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
hoverable checkable backend-sorting @sort="onSort"> hoverable checkable backend-sorting @sort="onSort">
<template slot="top-left">
<a href='' @click.prevent="exportSubscribers">
<b-icon icon="cloud-download-outline" size="is-small" /> {{ $t('subscribers.export') }}
</a>
</template>
<template slot-scope="props"> <template slot-scope="props">
<b-table-column field="status" label="Status" sortable> <b-table-column field="status" :label="$t('globals.fields.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>
<b-table-column field="email" label="E-mail" sortable> <b-table-column field="email" :label="$t('subscribers.email')" sortable>
<a :href="`/subscribers/${props.row.id}`" <a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)"> @click.prevent="showEditForm(props.row)">
{{ props.row.email }} {{ props.row.email }}
@ -112,46 +128,47 @@
<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>
</b-table-column> </b-table-column>
<b-table-column field="name" label="Name" sortable> <b-table-column field="name" :label="$t('globals.fields.name')" sortable>
<a :href="`/subscribers/${props.row.id}`" <a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)"> @click.prevent="showEditForm(props.row)">
{{ props.row.name }} {{ props.row.name }}
</a> </a>
</b-table-column> </b-table-column>
<b-table-column field="lists" label="Lists" numeric centered> <b-table-column field="lists" :label="$t('globals.terms.lists')" numeric centered>
{{ listCount(props.row.lists) }} {{ listCount(props.row.lists) }}
</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>
<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>
@ -183,6 +200,7 @@ import { mapState } from 'vuex';
import SubscriberForm from './SubscriberForm.vue'; import SubscriberForm from './SubscriberForm.vue';
import SubscriberBulkList from './SubscriberBulkList.vue'; import SubscriberBulkList from './SubscriberBulkList.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue'; import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import { uris } from '../constants';
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -320,13 +338,13 @@ export default Vue.extend({
deleteSubscriber(sub) { deleteSubscriber(sub) {
this.$utils.confirm( this.$utils.confirm(
'Are you sure?', null,
() => { () => {
this.$api.deleteSubscriber(sub.id).then(() => { this.$api.deleteSubscriber(sub.id).then(() => {
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 +372,16 @@ export default Vue.extend({
}; };
} }
this.$utils.confirm( this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), fn);
`Blocklist ${this.numSelectedSubscribers} subscriber(s)?`, },
fn,
); exportSubscribers() {
this.$utils.confirm(this.$t('subscribers.confirmExport', { num: this.subscribers.total }), () => {
const q = new URLSearchParams();
q.append('query', this.queryParams.queryExp);
q.append('list_id', this.queryParams.listID);
document.location.href = `${uris.exportSubscribers}?${q.toString()}`;
});
}, },
deleteSubscribers() { deleteSubscribers() {
@ -371,7 +395,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 +411,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 +419,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 +443,7 @@ export default Vue.extend({
fn(data).then(() => { fn(data).then(() => {
this.querySubscribers(); this.querySubscribers();
this.$buefy.toast.open({ this.$buefy.toast.open({
message: 'List change applied', message: this.$t('subscribers.listChangeApplied'),
type: 'is-success', type: 'is-success',
queue: false, queue: false,
}); });

View File

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

View File

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

10
frontend/yarn.lock vendored
View File

@ -8531,6 +8531,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
textversionjs@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/textversionjs/-/textversionjs-1.1.3.tgz#1b700aef780467786882e28ab126f77ca326a1e8"
integrity sha1-G3AK73gEZ3hoguKKsSb3fKMmoeg=
thenify-all@^1.0.0: thenify-all@^1.0.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
@ -8986,6 +8991,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"

420
i18n/de.json Normal file
View File

@ -0,0 +1,420 @@
{
"_.code": "de",
"_.name": "Deutsch (de)",
"admin.errorMarshallingConfig": "Fehler beim einlesen der Konfigration: {error}",
"campaigns.cantUpdate": "Eine laufende oder abgeschlossene Kampagne kann nicht geändert werden",
"campaigns.clicks": "Klicks",
"campaigns.confirmDelete": "Lösche {name}",
"campaigns.confirmSchedule": "Diese Kampagne started zu einem konfigurierten Zeitpunkt. Jetzt starten?",
"campaigns.confirmSwitchFormat": "Du wirst die Formatierung des Inhalts vielleicht verlieren. Fortfahren?",
"campaigns.content": "Inhalt",
"campaigns.contentHelp": "Inhalt hier",
"campaigns.continue": "Fortsetzen",
"campaigns.copyOf": "Kopie von {name}",
"campaigns.dateAndTime": "Datum und Zeit",
"campaigns.ended": "Beendet",
"campaigns.errorSendTest": "Fehler beim senden der Testmail: {error}",
"campaigns.fieldInvalidBody": "Fehler beim erstellen des Kampagneninhalts: {error}",
"campaigns.fieldInvalidFromEmail": "Ungültiges Format `from_email`.",
"campaigns.fieldInvalidListIDs": "Ungültige Listen IDs.",
"campaigns.fieldInvalidMessenger": "Unbekannter Messenger {name}.",
"campaigns.fieldInvalidName": "Ungültige Länge für `name`.",
"campaigns.fieldInvalidSendAt": "`send_at` Datum muss in der Zukunft liegen.",
"campaigns.fieldInvalidSubject": "Ungültige Länge für `subject`.",
"campaigns.fromAddress": "Absender Adresse",
"campaigns.fromAddressPlaceholder": "Dein Name <noreply@deineseite.de>",
"campaigns.invalid": "Ungültige Kampagne",
"campaigns.needsSendAt": "Die Kampgane benötigt eine `send_at` Sendedatum um automatisch verschickt zu werden.",
"campaigns.newCampaign": "Neue Kampagne",
"campaigns.noKnownSubsToTest": "Keine Abonnenten für den Test vorhanden.",
"campaigns.noOptinLists": "Keine Opt-In Liste gefunden um die Kampagne anzulegen.",
"campaigns.noSubs": "In den ausgewählten Listen sind keine Abonnenten vorhanden. Die Kampagne kann nicht angelegt werden.",
"campaigns.noSubsToTest": "Das Ziel hat keine Abonnenten.",
"campaigns.notFound": "Kampagne nicht gefunden.",
"campaigns.onlyActiveCancel": "Nur aktive Kampagnen können abgebrochen werden.",
"campaigns.onlyActivePause": "Nur aktive Kampagnen können pausiert werden.",
"campaigns.onlyDraftAsScheduled": "Nur Kampagnen in Vorbereitung können geplant werden.",
"campaigns.onlyPausedDraft": "Nur Kampagnen in Vorbereitung oder pausierte Kampagnen können gestartet werden.",
"campaigns.onlyScheduledAsDraft": "Nur Kampagnen in Vorbereitung können als Vorbereitung gespeichert werden.",
"campaigns.pause": "Pause",
"campaigns.plainText": "Unformatierter Text",
"campaigns.preview": "Vorschau",
"campaigns.progress": "Fortschritt",
"campaigns.queryPlaceholder": "Name oder Betreff",
"campaigns.rawHTML": "HTML Code",
"campaigns.richText": "Rich text",
"campaigns.schedule": "Kampagne planen",
"campaigns.scheduled": "geplant",
"campaigns.send": "Senden",
"campaigns.sendLater": "Später senden",
"campaigns.sendTest": "Testnachricht versenden",
"campaigns.sendTestHelp": "Enter nach einer E-Mail-Adresse um mehrere Adressaten hinzuzufügen. Die Adressaten müssen Abonnenten sein.",
"campaigns.sendToLists": "Listen an die gesendet wird:",
"campaigns.sent": "Gesendet",
"campaigns.start": "Kampagne starten",
"campaigns.started": "\"{name}\" gestartet",
"campaigns.startedAt": "Gestartet",
"campaigns.stats": "Statistiken",
"campaigns.status.cancelled": "Abgebrochen",
"campaigns.status.draft": "Entwurf",
"campaigns.status.finished": "Beendet",
"campaigns.status.paused": "Pausiert",
"campaigns.status.running": "Laufend",
"campaigns.status.scheduled": "Geplant",
"campaigns.statusChanged": "\"{name}\" ist {status}",
"campaigns.subject": "Betreff",
"campaigns.testEmails": "E-Mails",
"campaigns.testSent": "Testnachricht gesendet",
"campaigns.timestamps": "Zeitstempel",
"campaigns.views": "Ansichten",
"dashboard.campaignViews": "Kampagnenansichten",
"dashboard.linkClicks": "Linkklicks",
"dashboard.messagesSent": "Nachrichten gesendet",
"dashboard.orphanSubs": "Verwaiste",
"email.data.info": "Eine Kopie aller gespeicherten Daten sind in der angehängten JSON datei gespeichert. Sie kann in einem Texteditor angezeigt werden.",
"email.data.title": "Deine Daten",
"email.optin.confirmSub": "Abonnement bestätigen",
"email.optin.confirmSubHelp": "Bestätige dein Abonnement mit einem Klick auf den nachfolgenden Knopf.",
"email.optin.confirmSubInfo": "Du hast dich erfolgreich für folgende Listen angemeldet:",
"email.optin.confirmSubTitle": "Abonnement bestätigen",
"email.optin.confirmSubWelcome": "Hallo {name},",
"email.optin.privateList": "Private Liste",
"email.status.campaignReason": "Grund",
"email.status.campaignSent": "Gesendet",
"email.status.campaignUpdateTitle": "Kampagnen Update",
"email.status.importFile": "Datei",
"email.status.importRecords": "Aufzeichnungen",
"email.status.importTitle": "Update Importieren",
"email.status.status": "Status",
"email.unsub": "Abmelden",
"email.unsubHelp": "Du möchtest diese E-Mails nicht mehr?",
"forms.formHTML": "Formular HTML",
"forms.formHTMLHelp": "Benutze den folgenden HTML Code um das Formular zum anmelden auf einer externen Seite anzuseigen. Das Formuar sollte das `email` Feld und eins oder mehr `l` (Listen UUID) Felder. `name` ist optional.",
"forms.publicLists": "Öffentliche Listen",
"forms.selectHelp": "Wähle die Listen die du zum Formulat hinzufügen möchtest.",
"forms.title": "Formulate",
"globals.buttons.add": "Hinzufügen",
"globals.buttons.addNew": "Neu hinzufügen",
"globals.buttons.cancel": "Abbrechen",
"globals.buttons.clone": "Klonen",
"globals.buttons.close": "Schließen",
"globals.buttons.continue": "Fortfahren",
"globals.buttons.delete": "Löschen",
"globals.buttons.edit": "Bearbeiten",
"globals.buttons.enabled": "Aktiviert",
"globals.buttons.learnMore": "Erfahre mehr",
"globals.buttons.new": "Neu",
"globals.buttons.ok": "Ok",
"globals.buttons.remove": "Entfernen",
"globals.buttons.save": "Speichern",
"globals.buttons.saveChanges": "Änderungen speichern",
"globals.days.1": "Mo",
"globals.days.2": "Di",
"globals.days.3": "Mi",
"globals.days.4": "Do",
"globals.days.5": "Fr",
"globals.days.6": "Sa",
"globals.days.7": "So",
"globals.fields.createdAt": "Erstellt",
"globals.fields.id": "ID",
"globals.fields.name": "Name",
"globals.fields.status": "Status",
"globals.fields.type": "Typ",
"globals.fields.updatedAt": "Aktualisiert",
"globals.fields.uuid": "UUID",
"globals.messages.confirm": "Bist du sicher?",
"globals.messages.created": "\"{name}\" erstellt",
"globals.messages.deleted": "\"{name}\" gelöscht",
"globals.messages.emptyState": "Hier ist nichts",
"globals.messages.errorCreating": "Fehler beim erstellen von {name}: {error}",
"globals.messages.errorDeleting": "Fehler beim löschen von {name}: {error}",
"globals.messages.errorFetching": "Fehler beim abrufen von {name}: {error}",
"globals.messages.errorUUID": "Fehler beim erzeugen einer UUID: {error}",
"globals.messages.errorUpdating": "Fehler beim aktualisieren von {name}: {error}",
"globals.messages.invalidID": "Ungültige ID",
"globals.messages.invalidUUID": "Ungültige UUID",
"globals.messages.notFound": "{name} nicht gefunden",
"globals.messages.passwordChange": "Gib dein Passwort für die Änderung ein",
"globals.messages.updated": "\"{name}\" aktualisiert",
"globals.months.1": "Jan",
"globals.months.10": "Okt",
"globals.months.11": "Nov",
"globals.months.12": "Dez",
"globals.months.2": "Feb",
"globals.months.3": "Mar",
"globals.months.4": "Apr",
"globals.months.5": "Mai",
"globals.months.6": "Jun",
"globals.months.7": "Jul",
"globals.months.8": "Aug",
"globals.months.9": "Sep",
"globals.terms.campaign": "Kampagne | Kampagnen",
"globals.terms.campaigns": "Kampagnen",
"globals.terms.dashboard": "Überblick",
"globals.terms.list": "Liste | Listen",
"globals.terms.lists": "Listen",
"globals.terms.media": "Medien | Medien",
"globals.terms.messenger": "Nachrichtendienst | Nachrichtendienste",
"globals.terms.messengers": "Nachrichtendienste",
"globals.terms.settings": "Einstellungen",
"globals.terms.subscriber": "Abonnent | Abonnenten",
"globals.terms.subscribers": "Abonnenten",
"globals.terms.tag": "Tag | Tags",
"globals.terms.tags": "Tags",
"globals.terms.template": "Template | Templates",
"globals.terms.templates": "Templates",
"import.alreadyRunning": "Es läuft gerade ein Importvorgang. Bitte warte bis dieser beendet ist und versuche es noch einmal.",
"import.blocklist": "Sperrliste",
"import.csvDelim": "CSV Trennzeichen",
"import.csvDelimHelp": "Standard Trennzeichen ist Komma.",
"import.csvExample": "Beispiel CSV(Rohdaten)",
"import.csvFile": "CSV oder ZIP Datei",
"import.csvFileHelp": "Klicke oder ziehe eine CSV oder ZIP Datei hierher",
"import.errorCopyingFile": "Fehler beim kopieren der Datei: {error}",
"import.errorProcessingZIP": "Fehler beim verarbeiten der ZIP Datei: {error}",
"import.errorStarting": "Fehler beim Import: {error}",
"import.importDone": "Abgeschlossen",
"import.importStarted": "Import gestartet",
"import.instructions": "Anleitung",
"import.instructionsHelp": "Lade eine CSV Datei (auch gepackt in einer ZIP Datei) hoch um eine liste von Abonnenten zu importieren. Die CSV Datei muss folgende Spalten mit den exakten namen haben. Attribute (optional) müssen valides JSON format mit escapted doppelten Anführungszeichen.",
"import.invalidDelim": "`delim` muss ein einzelnes Zeichen sein",
"import.invalidFile": "Ungültige Datei: {error}",
"import.invalidMode": "Ungültiger Modus",
"import.invalidParams": "Ungüliger Parameter: {error}",
"import.listSubHelp": "Listen die Abonniert werden.",
"import.mode": "Mode",
"import.overwrite": "Überschreiben?",
"import.overwriteHelp": "Überschreibe Name und Attribute von bestehenden Abonnenten?",
"import.recordsCount": "{num} / {total} Einträge",
"import.stopImport": "Import soppen",
"import.subscribe": "Abonnieren",
"import.title": "Abonnenten importieren",
"import.upload": "Hochladen",
"lists.confirmDelete": "Bist du sicher? Dies löscht keine Abonnenten.",
"lists.confirmSub": "Bestätige das/die Abonnement/s von {name}",
"lists.invalidName": "Ungültiger Name",
"lists.newList": "Neue Liste",
"lists.optin": "Opt-In",
"lists.optinHelp": "Double Opt-In sendet eine E-Mail an den Abonnenten mit der Frage nach Bestätigung. Kampagnen werden nur an bestätigte Abonnenten gesendet.",
"lists.optinTo": "Opt-In für {name}",
"lists.optins.double": "Double Opt-In",
"lists.optins.single": "Einfache Anmeldung",
"lists.sendCampaign": "Kampagne abschicken",
"lists.sendOptinCampaign": "Opt-In Kampagne senden",
"lists.type": "Typ",
"lists.typeHelp": "Öffentliche Listen können von allen abonniert werden. Die namen der Abonnenten könnten auf einer Öffentlichen Seite, wie der Verwaltungsseite auftauceh.",
"lists.types.private": "Privat",
"lists.types.public": "Öffentlich",
"logs.title": "Logs",
"media.errorReadingFile": "Fehler beim lesen der Datei: {error}",
"media.errorResizing": "Fehler beim anpassen der Größe des Bildes: {error}",
"media.errorSavingThumbnail": "Fehler beim speichern des Thumbnails: {error}",
"media.errorUploading": "Fehler beim hochladen der Datei: {error}",
"media.invalidFile": "Ungültige Datei: {error}",
"media.title": "Medien",
"media.unsupportedFileType": "Nicht unterstützter Dateityp ({type})",
"media.upload": "Upload",
"media.uploadHelp": "Klicken oder ziehe ein oder mehrere Bilder hierhin",
"media.uploadImage": "Bilder Upload",
"menu.allCampaigns": "Alle Kampagnen",
"menu.allLists": "Alle Listen",
"menu.allSubscribers": "Alle Abonnenten",
"menu.dashboard": "Dashboard",
"menu.forms": "Formulare",
"menu.import": "Import",
"menu.logs": "Logs",
"menu.media": "Medien",
"menu.newCampaign": "Neu anlegen",
"menu.settings": "Einstellungen",
"public.campaignNotFound": "Die E-Mail Nachricht wurde nicht gefunden.",
"public.confirmOptinSubTitle": "Abonnement bestätigen",
"public.confirmSub": "Abonnement bestätigen",
"public.confirmSubInfo": "Du hast dich zu folgenden Listen angemeldet:",
"public.confirmSubTitle": "Bestätigen",
"public.dataRemoved": "Deine Anmeldung und alle Daten wurde entfernt.",
"public.dataRemovedTitle": "Daten gelöscht",
"public.dataSent": "Deine Daten wurden dir per E-Mail Anhang gesendet.",
"public.dataSentTitle": "Daten gesendet",
"public.errorFetchingCampaign": "Fehler beim abrufen der E-Mail",
"public.errorFetchingEmail": "E-Mail Nachricht nicht gefunden",
"public.errorFetchingLists": "Fehler beim abrufen der Listen. Bitte noch einmal probieren.",
"public.errorProcessingRequest": "Fehler bei der Anfrage. Bitte noch einmal probieren.",
"public.errorTitle": "Fehler",
"public.invalidFeature": "Dieses Feature ist nicht verfügbar",
"public.invalidLink": "Ungültiger Link",
"public.noSubInfo": "Es gibt keine zu Bestätigenden Abonnements",
"public.noSubTitle": "Keine Abonnements",
"public.notFoundTitle": "Nicht gefunden",
"public.privacyConfirmWipe": "Bist du sicher, dass du alle Abonnements und Daten löschen möchtest?",
"public.privacyExport": "Daten exportieren",
"public.privacyExportHelp": "Eine Kopie der gespeicherten Daten wird an deine E-Mail-Adresse versandt.",
"public.privacyTitle": "Privatsphäre und Datenschutz",
"public.privacyWipe": "Alle Daten löschen.",
"public.privacyWipeHelp": "Alle deine Daten und Abonnements, sowie die dazugehörigen Daten werden dauerhaft gelöscht.",
"public.subConfirmed": "Abonnement erfolgreich",
"public.subConfirmedTitle": "Bestätigt",
"public.subNotFound": "Abonnement nicht gefunden.",
"public.subPrivateList": "Private Liste",
"public.unsub": "Abmelden",
"public.unsubFull": "Auch von allen zukünftigen E-Mails abmelden.",
"public.unsubHelp": "Möchtest du dich von der Liste abmelden?",
"public.unsubTitle": "Abmelden",
"public.unsubbedInfo": "Du wurdest erfolgreich abgemeldet",
"public.unsubbedTitle": "Abgemeldet",
"public.unsubscribeTitle": "Von einer Liste abmelden.",
"settings.duplicateMessengerName": "Doppelter Nachrichtendienstname: {name}",
"settings.errorEncoding": "Fehler bei der Codierung der Einstellungen: {error}",
"settings.errorNoSMTP": "Mindestens ein SMTP Block muss aktiviert sein",
"settings.general.adminNotifEmails": "Admin Benachrichtigungen",
"settings.general.adminNotifEmailsHelp": "Komma getrennte Liste von E-Mail Adressen welche Admin Benachrichtigungen erhalten. Wie Importupdates, Fertigstellung von Kapganen, Fehler usw.",
"settings.general.faviconURL": "Favicon URL",
"settings.general.faviconURLHelp": "(Optional) komplette URL für ein statisches Favicon für die angezeigten Seiten (wie Abmelden).",
"settings.general.fromEmail": "Standard `von` E-Mail",
"settings.general.fromEmailHelp": "(Optional) Standard E-Mail für z.B. Abmeldungen.",
"settings.general.language": "Sprache",
"settings.general.logoURL": "Logo URL",
"settings.general.logoURLHelp": "(Optional) komplette URL für ein statisches Logo für die angezeigten Seiten (wie Abmelden).",
"settings.general.name": "Allgemein",
"settings.general.rootURL": "Root URL",
"settings.general.rootURLHelp": "Öffentliche URL der Installation (ohne Slash am Ende).",
"settings.invalidMessengerName": "Ungültiger Nachrichtendienstname",
"settings.media.provider": "Anbieter",
"settings.media.s3.bucket": "Bucket",
"settings.media.s3.bucketPath": "Bucket Pfad",
"settings.media.s3.bucketPathHelp": "Path im Bucket wo die Dateien hochgelanden werden sollen. Standard ist /",
"settings.media.s3.bucketType": "Bucket Typ",
"settings.media.s3.bucketTypePrivate": "Privat",
"settings.media.s3.bucketTypePublic": "Öffentlich",
"settings.media.s3.key": "AWS access key",
"settings.media.s3.region": "Region",
"settings.media.s3.secret": "AWS access secret",
"settings.media.s3.uploadExpiry": "Upload ablaufdatum",
"settings.media.s3.uploadExpiryHelp": "(Optional) Zeit bis zum Ablauf (in Sekunden) für die generierte URL. Nur für private Buckets. (s, m, h, d für Sekdunden, Minuten, Stunden, Tage).",
"settings.media.title": "Medien Uploads",
"settings.media.upload.path": "Upload Pfad",
"settings.media.upload.pathHelp": "Pfad zum Upload Verzeichnis.",
"settings.media.upload.uri": "Upload URI",
"settings.media.upload.uriHelp": "Upload URI welche öffentlich sichtbar ist. Die hochgeladenen Medien sind öffentlich erreich unter {root_url}, z.B. https://listmonk.yoursite.com/uploads.",
"settings.messengers.maxConns": "Max. Verbindungen",
"settings.messengers.maxConnsHelp": "Maximale gleichzeitige Verbindungen zum SMTP Server.",
"settings.messengers.messageDiscard": "Änderunge verwerfen?",
"settings.messengers.messageSaved": "Einstellungen gespeichert. App neu laden ...",
"settings.messengers.name": "Nachrichtendienste",
"settings.messengers.nameHelp": "z.B.: my-sms. Alphanumerisch / Bindestrich.",
"settings.messengers.password": "Passwort",
"settings.messengers.retries": "Versuche",
"settings.messengers.retriesHelp": "Anzahl der Wiederholungen wenn eine Nchricht fehlschlägt.",
"settings.messengers.skipTLSHelp": "TLS Zertifikat nicht prüfen.",
"settings.messengers.timeout": "Max. Wartezeit",
"settings.messengers.timeoutHelp": "Zeit bevor eine aktive Verbindung geschlossen und aus dem Pool entfernt wird. (s für Sekunden, m für Minuten).",
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL des Postback servers.",
"settings.messengers.username": "Benutzername",
"settings.performance.batchSize": "Batchgröße",
"settings.performance.batchSizeHelp": "Die Anzahl der Abonnenten die gleichzeitig von der Datenbank geladen werden. Jeder Schritt holt die Abonnenten und schickt die Nachrichten. Dies sollte idealerweise höher sein als der maximal erreichbare Durchsatz (Anzahl Threads * Nachrichtenrate).",
"settings.performance.concurrency": "Anzahl Threads",
"settings.performance.concurrencyHelp": "Maximal Anzahl von Threads die versuchen Nachrichten versenden.",
"settings.performance.maxErrThreshold": "Maximale Anzahl Fehler",
"settings.performance.maxErrThresholdHelp": "Die Anzahl der Fehler die tolleriert werden sollen bevor eine Kampagne für manuelle Kontrolle pausiert wird. 0 bedeutet kein Pausieren.",
"settings.performance.messageRate": "Nachrichtenrate",
"settings.performance.messageRateHelp": "Maximal Anzahl der Nachrichten die ein Thread pro Sekunde zu senden versucht. Z.B. wenn die Anzahl Threads auf 10 und die Nachrichtenrate auf 10 gestellt ist werden bis zu 10*10=100 Nachrichten pro Sekunden versendet. Bitte passenden zu den Serverlimits konfigurieren.",
"settings.performance.name": "Leistung",
"settings.performance.slidingWindow": "Zeitfenster aktivieren",
"settings.performance.slidingWindowDuration": "Dauer",
"settings.performance.slidingWindowDurationHelp": "Dauer des Zeitfensters(m für Minuten, h für Stunden)",
"settings.performance.slidingWindowHelp": "Begrenzt die Gesamtzahl der Nachrichten pro Zeit die gesendet werden. Wenn das Limit erreicht iwt wird gewartet bis das Zeitfenster abgelaufen ist bevor neue Nachrichten gesendet werden.",
"settings.performance.slidingWindowRate": "Max. Nachrichten",
"settings.performance.slidingWindowRateHelp": "Maximale Anzahl Nachrichten die innerhalb des Zeitfensters versendet werden",
"settings.privacy.allowBlocklist": "Aktiviere Blockierung",
"settings.privacy.allowBlocklistHelp": "Erlaube es Abonnenten ihre E-Mail-Adresse dauerhaft zu sperren?",
"settings.privacy.allowExport": "Export aktivieren",
"settings.privacy.allowExportHelp": "Erlaube Abonnenten alle ihre Daten zu exportieren?",
"settings.privacy.allowWipe": "Löschen aktivieren",
"settings.privacy.allowWipeHelp": "Erlaube Abonnenten alle Daten die über sie gespeichert sind zu löschen. auch Klicks und Anzeigen werden gelöscht, jedoch ohne die Gesamtzahl zu verändern. Statistiken werden also nicht geändert.",
"settings.privacy.individualSubTracking": "Einzelabonnenten Tracking",
"settings.privacy.individualSubTrackingHelp": "Abonnentenviews und Klicks werden einzeln getrackt. Wenn deaktiviert werden die Daten ohne Zuordnung zu Abonnenten gespeichert.",
"settings.privacy.listUnsubHeader": "Inkludiere `List-Unsubscribe` Header",
"settings.privacy.listUnsubHeaderHelp": "Inkludiere Header zum einfachen Abmelden in den E-Mails. Erlaubt den E-Mail Klients den Usern einen Ein Klick Abmeldug anzubieten.",
"settings.privacy.name": "Privatsphäre",
"settings.smtp.authProtocol": "Autentifizierungsprotokoll",
"settings.smtp.customHeaders": "Benutzerdefinierte Header",
"settings.smtp.customHeadersHelp": "(Optional) Array von Benutzerdefinierten E-Mail Headern welche in die Nachricht eingefügt werden sollen. Z.B.: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Aktiviert",
"settings.smtp.heloHost": "HELO Hostname",
"settings.smtp.heloHostHelp": "(Optional) Manche SMTP Server benötigen ein FQDN Hostname im HELO. default ist `localhost`. Setzen wenn ein anderer Wert verwendet werden soll.",
"settings.smtp.host": "Server",
"settings.smtp.hostHelp": "SMTP Server Adresse.",
"settings.smtp.idleTimeout": "Maximale Wartezeit",
"settings.smtp.idleTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
"settings.smtp.maxConns": "Max. Verbindungen",
"settings.smtp.maxConnsHelp": "Maximale gleichzeitige Verbindungen zum SMTP Server",
"settings.smtp.name": "SMTP",
"settings.smtp.password": "Passwort",
"settings.smtp.passwordHelp": "Eingeben um zu ändern",
"settings.smtp.port": "Port",
"settings.smtp.portHelp": "SMTP Server Port.",
"settings.smtp.retries": "Wiederholungen",
"settings.smtp.retriesHelp": "Maximale Anzahl Wiederholungen wenn eine Machricht fehlschlägt.",
"settings.smtp.setCustomHeaders": "Benutzerdefinierten Header verwenden",
"settings.smtp.skipTLS": "TLS Verifikation überspringen",
"settings.smtp.skipTLSHelp": "Überspringe die Hostname Prüfung im TLS Zertifikat.",
"settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Verwende STARTTLS.",
"settings.smtp.username": "Benutzername",
"settings.smtp.waitTimeout": "Maximale Wartezeit",
"settings.smtp.waitTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
"settings.title": "Einstellungen",
"subscribers.advancedQuery": "Erweitert",
"subscribers.advancedQueryHelp": "Partieller SQL Ausdruck um Attribute der Abonnenten abzufragen",
"subscribers.attribs": "Attribute",
"subscribers.attribsHelp": "Attribute sind als JSON Map definiert, z.B.:",
"subscribers.blocklistedHelp": "Blockierte Abonnenten werden nie mehr eine E-Mail bekommen.",
"subscribers.confirmBlocklist": "Blockiere {num} Abonnent(en)?",
"subscribers.confirmDelete": "Lösche {num} Abonnent(en)?",
"subscribers.confirmExport": "Exportiere {num} Abonnent(en)?",
"subscribers.downloadData": "Daten herunterladen",
"subscribers.email": "E-Mail",
"subscribers.emailExists": "E-Mail existiert bereits",
"subscribers.errorBlocklisting": "Fehler, Abonnement ist geblockt: {error}",
"subscribers.errorInvalidIDs": "Eine oder meherer IDs sind ungültig: {error}",
"subscribers.errorNoIDs": "Keine IDs Angegeben",
"subscribers.errorNoListsGiven": "Keine Listen angegeben",
"subscribers.errorPreparingQuery": "Fehler beim vorbereiten der Abonnentenabfrage: {error}",
"subscribers.errorSendingOptin": "Fehler beim sender der Opt-In E-Mail",
"subscribers.export": "Export",
"subscribers.invalidAction": "Ungültiger Vorgang",
"subscribers.invalidEmail": "Ungültige E-Mail",
"subscribers.invalidJSON": "Ungültiges JSON in den Attributen attributes",
"subscribers.invalidName": "Ungültiger Name",
"subscribers.listChangeApplied": "Änderungen an der Liste gespeichert",
"subscribers.lists": "Listen",
"subscribers.listsHelp": "Listen von denen sich Abonnenten selbst abgemeldet haben können nicht entfernt werden.",
"subscribers.listsPlaceholder": "Anmelden an den Listen ",
"subscribers.manageLists": "Listen verwalten",
"subscribers.markUnsubscribed": "Als Abgemeldet markieren",
"subscribers.newSubscriber": "Neuer Abonnent",
"subscribers.numSelected": "{num} Abonnent(en) ausgewählt",
"subscribers.optinSubject": "Abonnement bestätigen",
"subscribers.query": "Abfrage",
"subscribers.queryPlaceholder": "E-Mail oder Name",
"subscribers.reset": "Zurücksetzen",
"subscribers.selectAll": "Wähle alle {num}",
"subscribers.status.blocklisted": "Blockiert",
"subscribers.status.enabled": "Aktiviert",
"subscribers.status.subscribed": "Angemeldet",
"subscribers.status.unconfirmed": "Bestätigung ausstehend",
"subscribers.status.unsubscribed": "Abgemeldet",
"subscribers.subscribersDeleted": "{num} Abonnenten gelöscht",
"templates.cantDeleteDefault": "Das Standardtemplate kann nicht gelöscht werden",
"templates.default": "Standard",
"templates.dummyName": "Test Kampagne",
"templates.dummySubject": "Test Kampagnen name",
"templates.errorCompiling": "Fehler beim kompilieren des Templates: {error}",
"templates.errorRendering": "Fehler beim Rendern der Nachricht: {error}",
"templates.fieldInvalidName": "Ungültige Länge für `name`.",
"templates.makeDefault": "Als Standard",
"templates.newTemplate": "Neues Template",
"templates.placeholderHelp": "Der Platzhalter {placeholder} darf nur genau einmal im Template vorkommen.",
"templates.preview": "Vorschau",
"templates.rawHTML": "Raw HTML"
}

432
i18n/en.json Normal file
View File

@ -0,0 +1,432 @@
{
"_.code": "en",
"_.name": "English (en)",
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
"campaigns.addAltText": "Add alternate plain text message",
"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.confirmSwitchFormat": "The content may lose formatting. Continue?",
"campaigns.content": "Content",
"campaigns.contentHelp": "Content here",
"campaigns.continue": "Continue",
"campaigns.copyOf": "Copy of {name}",
"campaigns.dateAndTime": "Date and time",
"campaigns.ended": "Ended",
"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": "Scheduled 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 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.plainText": "Plain text",
"campaigns.preview": "Preview",
"campaigns.progress": "Progress",
"campaigns.queryPlaceholder": "Name or subject",
"campaigns.rawHTML": "Raw HTML",
"campaigns.removeAltText": "Remove alternate plain text message",
"campaigns.richText": "Rich text",
"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.startedAt": "Started",
"campaigns.stats": "Stats",
"campaigns.status.cancelled": "Cancelled",
"campaigns.status.draft": "Draft",
"campaigns.status.finished": "Finished",
"campaigns.status.paused": "Paused",
"campaigns.status.running": "Running",
"campaigns.status.scheduled": "Scheduled",
"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.campaignUpdateTitle": "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.noPublicLists": "There are no public lists to generate a forms.",
"forms.publicLists": "Public lists",
"forms.publicSubPage": "Public subscription page",
"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.continue": "Continue",
"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.days.1": "Mon",
"globals.days.2": "Tue",
"globals.days.3": "Wed",
"globals.days.4": "Thu",
"globals.days.5": "Fri",
"globals.days.6": "Sat",
"globals.days.7": "Sun",
"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.months.1": "Jan",
"globals.months.10": "Oct",
"globals.months.11": "Nov",
"globals.months.12": "Dec",
"globals.months.2": "Feb",
"globals.months.3": "Mar",
"globals.months.4": "Apr",
"globals.months.5": "May",
"globals.months.6": "Jun",
"globals.months.7": "Jul",
"globals.months.8": "Aug",
"globals.months.9": "Sep",
"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": "Delimiter 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.mode": "Mode",
"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.sendOptinCampaign": "Send opt-in 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.import": "Import",
"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.noListsAvailable": "No lists available to subscribe.",
"public.noListsSelected": "No valid lists selected to subscribe.",
"public.noSubInfo": "There are no subscriptions to confirm.",
"public.noSubTitle": "No subscriptions",
"public.notFoundTitle": "Not found",
"public.privacyConfirmWipe": "Are you sure you want to delete all your subscription data permanently?",
"public.privacyExport": "Export your data",
"public.privacyExportHelp": "A copy of your data will be e-mailed to you.",
"public.privacyTitle": "Privacy and data",
"public.privacyWipe": "Wipe your data",
"public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.",
"public.sub": "Subscribe",
"public.subConfirmed": "Subscribed successfully.",
"public.subConfirmedTitle": "Confirmed",
"public.subName": "Name (optional)",
"public.subNotFound": "Subscription not found.",
"public.subPrivateList": "Private list",
"public.subTitle": "Subscribe",
"public.unsub": "Unsubscribe",
"public.unsubFull": "Also unsubscribe from all future e-mails.",
"public.unsubHelp": "Do you want to unsubscribe from this mailing list?",
"public.unsubTitle": "Unsubscribe",
"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.enablePublicSubPage": "Enable public subscription page",
"settings.general.enablePublicSubPageHelp": "Show a public subscription page with all the public lists for people to subscribe.",
"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": "Default `from` e-mail to show on outgoing campaign e-mails. This can be changed per campaign.",
"settings.general.language": "Language",
"settings.general.logoURL": "Logo 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 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 retry 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 database 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.performance.slidingWindow": "Enable sliding window limit",
"settings.performance.slidingWindowDuration": "Duration",
"settings.performance.slidingWindowDurationHelp": "Duration of the sliding window period (m for minute, h for hour).",
"settings.performance.slidingWindowHelp": "Limit the total number of messages that are sent out in given period. On reaching this limit, messages are be held from sending until the time window clears.",
"settings.performance.slidingWindowRate": "Max. messages",
"settings.performance.slidingWindowRateHelp": "Maximum number of messages to send within the window duration.",
"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 retry 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.confirmExport": "Export {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.export": "Export",
"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 subscribe 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.confirmed": "Confirmed",
"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"
}

420
i18n/ml.json Normal file
View File

@ -0,0 +1,420 @@
{
"_.code": "ml",
"_.name": "മലയാളം (ml)",
"admin.errorMarshallingConfig": "അഭ്യർത്ഥന ക്രമീകരിയ്ക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
"campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.",
"campaigns.clicks": "ക്ലീക്കുകൾ",
"campaigns.confirmDelete": "{name} നീക്കം ചെയ്യുക",
"campaigns.confirmSchedule": "ഈ ക്യാമ്പേയ്ൻ സ്വമേധയാ, മുൻകൂട്ടി നിശ്ചയിച്ച സമയത്ത് ആരംഭിക്കും. ഇപ്പോൾ ആരംഭിക്കട്ടെ?",
"campaigns.confirmSwitchFormat": "ഉള്ളടക്കത്തിന്റെ രൂപഘടന നഷ്ടപ്പെട്ടേക്കും. തുടരട്ടേ?",
"campaigns.content": "ഉള്ളടക്കം",
"campaigns.contentHelp": "ഇവിടെ ഉള്ളടക്കം നൽകുക",
"campaigns.continue": "തുടരൂ",
"campaigns.copyOf": "{name} ന്റെ പകർപ്പ്",
"campaigns.dateAndTime": "തിയതിയും സമയവും",
"campaigns.ended": "അവസാനിച്ചു",
"campaigns.errorSendTest": "ടെസ്റ്റ് അയയ്ക്കുന്നത് പരാജയപ്പെട്ടു: {error}",
"campaigns.fieldInvalidBody": "ക്യാമ്പേയ്ന്റെ ചട്ടക്കൂട് തയ്യാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു : {error}",
"campaigns.fieldInvalidFromEmail": "`from_email` അസാധുവാണ്.",
"campaigns.fieldInvalidListIDs": "ലിസ്റ്റ് ഐഡികൾ അസാധുവാണ്.",
"campaigns.fieldInvalidMessenger": "ദൂതൻ {name} അജ്ഞാതനാണ്.",
"campaigns.fieldInvalidName": "`name` ന്റെ ദൈർഘ്യം അസാധുവാണ്.",
"campaigns.fieldInvalidSendAt": "`send_at` ഭാവിയിലുള്ള തിയതിയായിരിക്കണം.",
"campaigns.fieldInvalidSubject": "`subject` ന്റെ ദൈർഘ്യം അസാധുവാണ്.",
"campaigns.fromAddress": "പ്രേക്ഷകൻ",
"campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>",
"campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്",
"campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.",
"campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ",
"campaigns.noKnownSubsToTest": "ടെസ്റ്റ് ചെയ്യാൻ, വരിക്കാരുടെ പട്ടിക ശൂന്യമാണ്.",
"campaigns.noOptinLists": "പുതിയ ക്യാമ്പേയ്ൻ ആരംഭിയ്ക്കാൻ ലിസ്റ്റുകളൊന്നും കണ്ടെത്തിയില്ല.",
"campaigns.noSubs": "പുതിയ ക്യാമ്പേയ്ൻ ആരംഭിയ്ക്കാനായി തിരഞ്ഞെടുത്ത ലിസ്റ്റിൽ വരിക്കാരാരുമില്ല.",
"campaigns.noSubsToTest": "ലക്ഷ്യം വെക്കാൻ വരിക്കാരാരുമില്ല.",
"campaigns.notFound": "ക്യാമ്പേയ്ൻ കണ്ടെത്തിയില്ല",
"campaigns.onlyActiveCancel": "ഇപ്പോൾ സജീവമായ ക്യാമ്പേയ്നുകൾ മാത്രമേ റദ്ദാക്കാനാകൂ.",
"campaigns.onlyActivePause": "ഇപ്പോൾ സജീവമായ ക്യാമ്പേയ്നുകൾ മാത്രമേ താത്കാലികമായി നിർത്താനാകൂ.",
"campaigns.onlyDraftAsScheduled": "ഡ്രാഫ്റ്റ് ക്യാമ്പേയ്നുകൾ മാത്രമേ ആസൂത്രണം ചെയ്യാനാകൂ.",
"campaigns.onlyPausedDraft": "താത്കാലികമായി നിർത്തിയതോ ഡ്രാഫ്റ്റോ ആയ ക്യാമ്പേയ്നുകൾ മാത്രമേ ആരംഭിയ്ക്കാനാകൂ.",
"campaigns.onlyScheduledAsDraft": "മുൻകൂട്ടി ആസൂത്രണം ചെയ്ത ക്യാമ്പേയ്നുകൾ മാത്രമേ ഡ്രാഫ്റ്റായി സംരക്ഷിക്കാനാകൂ.",
"campaigns.pause": "താത്കാലികമായി നിർത്തുക",
"campaigns.plainText": "പ്ലെയിൻ ടെക്സ്റ്റ്",
"campaigns.preview": "പ്രിവ്യൂ",
"campaigns.progress": "പുരോഗതി",
"campaigns.queryPlaceholder": "പേരോ വിഷയമോ",
"campaigns.rawHTML": "അസംസ്കൃത എച്. ടി. എം. എൽ",
"campaigns.richText": "റിച്ച് ടെക്സ്റ്റ്",
"campaigns.schedule": "ക്യാമ്പേയ്ൻ ആസൂത്രണം ചെയ്യുക",
"campaigns.scheduled": "ആസൂത്രണം ചെയ്തു",
"campaigns.send": "അയക്കു",
"campaigns.sendLater": "പിന്നീട് അയക്കുക",
"campaigns.sendTest": "ടെസ്റ്റ് സന്ദേശം അയക്കുക",
"campaigns.sendTestHelp": "ഒന്നിലധികം സ്വീകർത്താക്കളുടെ വിലാസം രേഖപ്പെടുത്തിയ ശേഷം എന്റർ കീ അമർത്തുക. വിലാസങ്ങൾ നിലവിലുള്ള വരിക്കാരുടേതായിരിക്കണം.",
"campaigns.sendToLists": "അയക്കാനായുള്ള ലിസ്റ്റ്",
"campaigns.sent": "അയച്ചു",
"campaigns.start": "ക്യാമ്പേയ്ൻ ആരംഭിയ്ക്കുക",
"campaigns.started": "\"{name}\" ആരംഭിച്ചു",
"campaigns.startedAt": "ആരംഭിച്ചു",
"campaigns.stats": "സ്ഥിതിവിവരക്കണക്കുകൾ ",
"campaigns.status.cancelled": "റദ്ദാക്കി",
"campaigns.status.draft": "ഡ്രാഫ്റ്റ് ",
"campaigns.status.finished": "പൂർത്തിയായി",
"campaigns.status.paused": "താൽക്കാലികമായി നിർത്തി",
"campaigns.status.running": "നടക്കുന്നു",
"campaigns.status.scheduled": "ആസൂത്രണം ചെയ്തു",
"campaigns.statusChanged": "\"{name}\" {status} ആണ്",
"campaigns.subject": "വിഷയം",
"campaigns.testEmails": "ഈ-മെയിലുകൾ",
"campaigns.testSent": "ടെസ്റ്റ് സന്ദേശം അയച്ചു",
"campaigns.timestamps": "സമയം",
"campaigns.views": "കാഴ്ചകൾ",
"dashboard.campaignViews": "ക്യാമ്പേയ്ൻ കാഴ്ചകൾ",
"dashboard.linkClicks": "കണ്ണിയിലെ ക്ലിക്കുകൾ",
"dashboard.messagesSent": "സന്ദേശം അയച്ചു",
"dashboard.orphanSubs": "അനാഥർ",
"email.data.info": "ജേസൺ ഫയൽ ഫോർമാറ്റിലുള്ള പ്രമാണത്തിന്റെ പകർപ്പ് ഇതിനോടൊപ്പം ചേർകക്കുന്നു. ടെക്സ്റ്റ് എഡിറ്ററുപയോഗിച്ച് കാണാനാകും.",
"email.data.title": "നിങ്ങളുടെ വിവരങ്ങള്‍",
"email.optin.confirmSub": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"email.optin.confirmSubHelp": "നിങ്ങൾ വരിക്കാരനാകുന്നത് താഴെയുള്ള ബട്ടണിൽ ഞെക്കിക്കൊണ്ട് സ്ഥിരീകരിക്കുക.",
"email.optin.confirmSubInfo": "നിങ്ങൾ താഴെപ്പറയുന്ന ലിസ്റ്റുകളിൽ അംഗമാണ്:",
"email.optin.confirmSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"email.optin.confirmSubWelcome": "നമസ്കാരം {name},",
"email.optin.privateList": "സ്വകാര്യ ലിസ്റ്റ്",
"email.status.campaignReason": "കാരണം",
"email.status.campaignSent": "അയച്ചു",
"email.status.campaignUpdateTitle": "ക്യാമ്പേയ്നിന്റെ വിശദാംശങ്ങൾ",
"email.status.importFile": "ഫയലുകൾ",
"email.status.importRecords": "റെക്കോഡുകൾ",
"email.status.importTitle": "അപ്ഡേറ്റ് ഇംപോർട്ട് ചെയ്യുക",
"email.status.status": "സ്ഥിതി",
"email.unsub": "വരിക്കാരനല്ലാതാകുക",
"email.unsubHelp": "ഈ-മെയിലുകൾ ഇനി സ്വീകരിക്കേണ്ടതില്ലേ?",
"forms.formHTML": "എച്. ടി. എം. എൽ ഫോം",
"forms.formHTMLHelp": "മറ്റൊരു വെബ് പേജിൽ സബ്സ്ക്രിപ്ഷൻ ഫോം കാണിയ്ക്കുന്നതിന് താഴെക്കൊടുത്തിരിക്കുന്ന എച്. ടി. എം. എൽ ഉപയോഗിക്കുക.",
"forms.publicLists": "പൊതു ലിസ്റ്റുകൾ",
"forms.selectHelp": "ഫോമിലേയ്ക്ക് ചേർക്കേണ്ട ലിസ്റ്റുകൾ.",
"forms.title": "ഫോമുകൾ",
"globals.buttons.add": "ചേർക്കുക",
"globals.buttons.addNew": "പുതിയത് ചേർക്കുക",
"globals.buttons.cancel": "ഉപേക്ഷിക്കുക",
"globals.buttons.clone": "ക്ലോൺ ചെയ്യുക",
"globals.buttons.close": "അടയ്ക്കുക",
"globals.buttons.continue": "തുടരുക",
"globals.buttons.delete": "നീക്കം ചെയ്യുക",
"globals.buttons.edit": "തിരുത്തുക",
"globals.buttons.enabled": "പ്രവർത്തനക്ഷമാക്കി",
"globals.buttons.learnMore": "കൂടുതൽ അറിയുക",
"globals.buttons.new": "പുതിയത്",
"globals.buttons.ok": "ശരി",
"globals.buttons.remove": "നീക്കം ചെയ്യുക",
"globals.buttons.save": "സൂക്ഷിക്കുക",
"globals.buttons.saveChanges": "മാറ്റങ്ങൾ സൂക്ഷിക്കുക",
"globals.days.1": "തിങ്കൾ",
"globals.days.2": "ചൊവ്വ",
"globals.days.3": "ബുധൻ",
"globals.days.4": "വ്യാഴം",
"globals.days.5": "വെള്ളി",
"globals.days.6": "ശനി",
"globals.days.7": "ഞായർ",
"globals.fields.createdAt": "നിർമ്മിച്ചത്",
"globals.fields.id": "ഐഡി",
"globals.fields.name": "പേര്",
"globals.fields.status": "സ്ഥിതി",
"globals.fields.type": "ശൈലി",
"globals.fields.updatedAt": "പുതുക്കിയത്",
"globals.fields.uuid": "യുയുഐഡി",
"globals.messages.confirm": "താങ്കൾക്ക് തീർച്ചയാണോ?",
"globals.messages.created": "\"{name}\" നിർമ്മിച്ചു",
"globals.messages.deleted": "\"{name}\" നീക്കം ചെയ്തു",
"globals.messages.emptyState": "ഇവിടൊന്നുമില്ല",
"globals.messages.errorCreating": "{name} നിർമ്മിക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
"globals.messages.errorDeleting": "{name} നീക്കം ചെയ്യുന്നതിൽ പിശകുണ്ടായി: {error}",
"globals.messages.errorFetching": "{name} കൊണ്ടുവരുന്നതിൽ പിശകുണ്ടായി: {error}",
"globals.messages.errorUUID": "യുയുഐഡി ഉണ്ടാക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
"globals.messages.errorUpdating": "{name} പുതുക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
"globals.messages.invalidID": "ഐഡി അസാധുവാണ്",
"globals.messages.invalidUUID": "യുയുഐഡി അസാധുവാണ്",
"globals.messages.notFound": "{name} കണ്ടെത്തിയില്ല",
"globals.messages.passwordChange": "മാറ്റം വരുത്തേണ്ട വില രേഖപ്പെടുത്തുക",
"globals.messages.updated": "\"{name}\" പുതുക്കി",
"globals.months.1": "ജനുവരി",
"globals.months.10": "ഒക്ടോബർ",
"globals.months.11": "നവംബർ",
"globals.months.12": "ഡിസംബർ",
"globals.months.2": "ഫെബ്രുവരി",
"globals.months.3": "മാർച്ച്",
"globals.months.4": "ഏപ്രിൽ",
"globals.months.5": "മെയ്",
"globals.months.6": "ജൂൺ",
"globals.months.7": "ജൂലൈ",
"globals.months.8": "ഓഗസ്റ്റ്",
"globals.months.9": "സെപ്റ്റംബർ",
"globals.terms.campaign": "ക്യാമ്പേയ്ൻ | ക്യാമ്പേയ്നുകൾ",
"globals.terms.campaigns": "ക്യാമ്പേയ്നുകൾ",
"globals.terms.dashboard": "ഡാഷ്ബോഡ്",
"globals.terms.list": "ലിസ്റ്റ് | ലിസ്റ്റുകൾ",
"globals.terms.lists": "ലിസ്റ്റുകൾ",
"globals.terms.media": "മീഡിയ | മീഡിയ",
"globals.terms.messenger": "സന്ദേശ വാഹകൻ | സന്ദേശ വാഹകർ",
"globals.terms.messengers": "സന്ദേശ വാഹകർ",
"globals.terms.settings": "ക്രമീകരണങ്ങൾ",
"globals.terms.subscriber": "വരിക്കാരൻ | വരിക്കാർ",
"globals.terms.subscribers": "വരിക്കാർ",
"globals.terms.tag": "ടാഗ് | ടാഗുകൾ",
"globals.terms.tags": "ടാഗുകൾ",
"globals.terms.template": "ടെംപ്ലേറ്റ് | ടെംപ്ലേറ്റുകൾ",
"globals.terms.templates": "ടെംപ്ലേറ്റുകൾ",
"import.alreadyRunning": "ഒരു ഇമ്പോർട്ട് ഇപ്പോൾ നടന്നുകൊണ്ടിരിക്കുന്നു. വീണ്ടും ശ്രമിക്കുന്നതിന് മുമ്പ് കാത്തിരിക്കുകയോ നടന്നുകൊണ്ടിരിക്കുന്ന ഇമ്പോർട്ട് നിർത്തുകയോ ചെയ്യുക.",
"import.blocklist": "തടയുന്ന പട്ടിക",
"import.csvDelim": "CSV യുടെ അതിർത്തി",
"import.csvDelimHelp": "കോമയാണ് സ്ഥിരസ്ഥിതി അതിർത്തി.",
"import.csvExample": "CSVയ്ക്ക് ഉദാഹരണം",
"import.csvFile": "CSVയോ ZIP ഫയലോ",
"import.csvFileHelp": "CSVയോ ZIPഓ വലിച്ചിട്ടോ അമർത്തിയോ ഇവിടെ കൊണ്ടുവരിക",
"import.errorCopyingFile": "ഫയൽ പകർത്തുന്നത് പൂർത്തിയാക്കാനായില്ല: {error}",
"import.errorProcessingZIP": "ZIP ഫയൽ കൈകാര്യം ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}",
"import.errorStarting": "ഇമ്പോർട്ട് ആരംഭിക്കുന്നതിൽ തടസം നേരിട്ടു: {error}",
"import.importDone": "കഴിഞ്ഞു",
"import.importStarted": "ഇംപോർട്ട് ആരംഭിച്ചു",
"import.instructions": "നിര്‍ദ്ധേശങ്ങൾ",
"import.instructionsHelp": "വരിക്കാരെ കൂട്ടത്തോടെ ചേർക്കാൻ ഒരു CSV ഫയലോ ZIP ഫയലോ അപ്ലോഡ് ചെയ്യുക. CSV ഫയലിൽ മേൽപ്പറയുന്ന തലക്കെട്ടുകളും നിരയുടെ പേരും ആവശ്യമാണ്. ഐച്ഛികമായ വിശേഷണങ്ങൾ ഇരട്ട ഉദ്ദരണികൾക്കിടയിലുള്ള ഒരു സാധുവായ ജേസൺ വാക്യമായിരിക്കണം.",
"import.invalidDelim": "`delim` ഒറ്റ അക്ഷരമായിരിക്കണം",
"import.invalidFile": " ഫയൽ അസാധുവാണ് : {error}",
"import.invalidMode": "ശൈലി അസാധുവാണ്",
"import.invalidParams": "പരാമുകൾ അസാധുവാണ്: {error}",
"import.listSubHelp": "വരിക്കാരനാകാനുള്ള ലിസ്റ്റുകൾ.",
"import.mode": "ശൈലി",
"import.overwrite": "തിരുത്തിയെഴുതട്ടേ?",
"import.overwriteHelp": "നിലവിലുള്ള വരിക്കാരുടെ പേരും മറ്റുവിവരങ്ങളും തിരുത്തിയെഴുതട്ടേ?",
"import.recordsCount": "{num} / {total} രേഖകള്‍",
"import.stopImport": "ഇംപോർട്ട് നിർത്തുക",
"import.subscribe": "വരിക്കാരാകുക",
"import.title": "വരിക്കാരേ ഇംപോർട്ട് ചെയ്യുക",
"import.upload": "അപ്ലോഡ്",
"lists.confirmDelete": "നിങ്ങൾക്ക് തീർച്ചയാണോ? ഇത് ലിസ്റ്റിലെ വരിക്കാരെ ഇല്ലാതാക്കില്ല.",
"lists.confirmSub": "{name} ൽ വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"lists.invalidName": "പേര് അസാധുവാണ്",
"lists.newList": "പുതിയ ലിസ്റ്റ്",
"lists.optin": "ചേരുക",
"lists.optinHelp": "ഇരട്ട ഓപ്റ്റ്-ഇൻ ൽ വരിക്കാരന് തീർപ്പുകൽപ്പിക്കുന്നതിന് ഇ-മെയിൽ അയക്കും. ഇരട്ട ഓപ്റ്റ്-ഇൻ ലിസ്റ്റിലേക്കുള്ള ക്യാമ്പേയ്നുകൾ സ്ഥിരീകരിച്ചവർക്ക് മാത്രമേ അയക്കൂ.",
"lists.optinTo": "{name} ൽ ചേരുക",
"lists.optins.double": "ഇരട്ട ഓപ്റ്റ്-ഇൻ",
"lists.optins.single": "ഓപ്റ്റ്-ഇൻ",
"lists.sendCampaign": "ക്യാമ്പേയ്ൻ അയക്കുക",
"lists.sendOptinCampaign": "ഓപ്റ്റ്-ഇൻ ക്യാമ്പേയ്ൻ അയക്കുക",
"lists.type": "ശൈലി",
"lists.typeHelp": "പൊതുവായ ലിസ്റ്റുകളിൽ ആർക്ക് വേണമെങ്കിലും വരിക്കാരനാകാം. അവരുടെ പേരുകൾ സബ്സ്ക്രിപ്ഷൻ മാനേജ്മെന്റ് പോലുള്ള പേജുകളിൽ ചിലപ്പോൾ കണ്ടേക്കാം.",
"lists.types.private": "സ്വകാര്യം",
"lists.types.public": "പൊതു",
"logs.title": "ലോഗുകൾ",
"media.errorReadingFile": "ഫയൽ വായിക്കാനായില്ല: {error}",
"media.errorResizing": "ചിത്രത്തിന്റ വലിപ്പം മാറ്റാനായില്ല: {error}",
"media.errorSavingThumbnail": "തമ്പ്നെയിൽ സേവ് ചെയ്യാനായില്ല: {error}",
"media.errorUploading": "ഫയൽ അപ്ലോഡ് ചെയ്യാനായില്ല: {error}",
"media.invalidFile": "ഫയൽ അസാധുവാണ്: {error}",
"media.title": "മീഡിയ",
"media.unsupportedFileType": "പിൻതുണക്കാത്ത തരം ഫയൽ({type})",
"media.upload": "അപ്ലോഡ്",
"media.uploadHelp": "ഒന്നോ അതിലധികമോ ചിത്രങ്ങൾ വലിച്ചിട്ടോ അമർത്തിയോ ഇവിടെ കൊണ്ടുവരിക",
"media.uploadImage": "ചിത്രം അപ്ലോഡ് ചെയ്യുക",
"menu.allCampaigns": "എല്ലാ ക്യാമ്പേയ്നുകളും",
"menu.allLists": "എല്ലാ ലിസ്റ്റുകളും",
"menu.allSubscribers": "എല്ലാ വരിക്കാരും",
"menu.dashboard": "ഡാഷ്ബോഡ്",
"menu.forms": "ഫോമുകൾ",
"menu.import": "ഇമ്പോർട്ട്",
"menu.logs": "ലോഗുകൾ",
"menu.media": "മീഡിയ",
"menu.newCampaign": "പുതിയത് തുടങ്ങുക",
"menu.settings": "ക്രമീകരണങ്ങൾ",
"public.campaignNotFound": "ഇ-മെയിൽ കണ്ടെത്താനായില്ല.",
"public.confirmOptinSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"public.confirmSub": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"public.confirmSubInfo": "താഴെപ്പറയുന്ന ലിസ്റ്റുകളിൽ നിങ്ങളെ ചേർത്തിട്ടുണ്ട്:",
"public.confirmSubTitle": "സ്ഥിരീകരിക്കുക",
"public.dataRemoved": "നിങ്ങളുടെ വരിക്കാരനായിരുന്നതിന്റെയും അനുബന്ധ വിവരങ്ങളും വിജയകരമായി നീക്കം ചെയ്തു.",
"public.dataRemovedTitle": "ഡാറ്റാ നീക്കം ചെയ്തു",
"public.dataSent": "നിങ്ങളുടെ ഡാറ്റാ അറ്റാച്ച്മെന്റായി നിങ്ങൾക്ക് ഇ-മെയിൽ ചെയ്തു.",
"public.dataSentTitle": "ഡാറ്റാ ഇ-മെയിൽ ചെയ്തു",
"public.errorFetchingCampaign": "ഇ-മെയിൽ വീണ്ടെടുക്കുന്നതിൽ തടസം നേരിട്ടു",
"public.errorFetchingEmail": "ഇ-മെയിൽ കണ്ടേത്തിയില്ല",
"public.errorFetchingLists": "ലിസ്റ്റുകൾ വീണ്ടെടുക്കുന്നതിൽ തടസം നേരിട്ടു. വീണ്ടും ശ്രമിക്കുക.",
"public.errorProcessingRequest": "അഭ്യർത്ഥനയിന്മേൽ നടപടിയെടുക്കുന്നതിൽ തടസം നേരിട്ടു. വീണ്ടും ശ്രമിക്കുക.",
"public.errorTitle": "എറർ",
"public.invalidFeature": "ഈ ഫീച്ചർ ലഭ്യമല്ല",
"public.invalidLink": "കണ്ണി അസാധുവാണ്",
"public.noSubInfo": "സ്ഥിരീകരിക്കാനായി വരിക്കാരനാകാനുള്ള അഭ്യർത്ഥനകളൊന്നുമില്ല",
"public.noSubTitle": "വരിക്കാരാരുമില്ല",
"public.notFoundTitle": "കണ്ടെത്തിയില്ല",
"public.privacyConfirmWipe": "വരിക്കാരനായിരിക്കുന്നതിന്റെ എല്ലാ വിവരങ്ങളും എന്നത്തേയ്ക്കുമായി നീക്കം ചെയ്യണമെന്ന് നിങ്ങളുൾക്കുറപ്പാണോ?",
"public.privacyExport": "നിങ്ങളുടെ വിവരങ്ങൾ എക്സ്പോർട്ട് ചെയ്യുക",
"public.privacyExportHelp": "വിവരങ്ങളുടെ ഒരു പകർപ്പ് നിങ്ങൾക്ക് ഇ-മെയിലായി അയച്ചു തരുന്നതാണ്.",
"public.privacyTitle": "സ്വകാര്യതയും വിവരങ്ങളും",
"public.privacyWipe": "നിങ്ങളുടെ വിവരങ്ങൾ എന്നന്നേയ്ക്കുമായി ഇല്ലാതാക്കുക",
"public.privacyWipeHelp": "താങ്കൾ വരിക്കാരനായിരിക്കുന്നതും അനുബന്ധ വിവരങ്ങളും ഡേറ്റാബേസിൽ നിന്നും എന്നത്തേയ്ക്കുമായി നീക്കം ചെയ്യുക.",
"public.subConfirmed": "വരിക്കാരനായി",
"public.subConfirmedTitle": "സ്ഥിരീകരിച്ചു",
"public.subNotFound": "വരിക്കാരനെ കണ്ടത്തിയില്ല.",
"public.subPrivateList": "സ്വകാര്യ ലിസ്റ്റ്",
"public.unsub": "വരിക്കാരനല്ലാതാകുക",
"public.unsubFull": "ഭാവിയിലുള്ള ഇ-മെയിലുകളിൽനിന്നും ഒഴിവാകുക.",
"public.unsubHelp": "ഇനിമേൽ ഈ ലിസ്റ്റിന്റെ വരിക്കാരനാകേണ്ട എന്നുറപ്പാണോ?",
"public.unsubTitle": "വരിക്കാരനല്ലാതാകുക",
"public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി",
"public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക",
"public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക",
"settings.duplicateMessengerName": "ഒരേ പേരിൽ ഒന്നിലധികം സന്ദശവാഹകർ: {name}",
"settings.errorEncoding": "ക്രമീകരണം എൻകോഡ് ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}",
"settings.errorNoSMTP": "കുറഞ്ഞപക്ഷം ഒരു എസ്. എം. ടീ. പീ ബ്ലൊക്കെങ്കിലും പ്രവർത്തനക്ഷമയിരിക്കണം",
"settings.general.adminNotifEmails": "കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പ് ഇ-മെയിലുകൾ",
"settings.general.adminNotifEmailsHelp": "ഇംപോർട്ട് ചെയ്തതിലുള്ള വിവരങ്ങൾ, ക്യാമ്പേയ്ൻ പൂർത്തീകരണം, പ്രശ്നങ്ങൾ എന്നിങ്ങനെയുള്ള പ്രധാനപ്പെട്ട കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പിനായുള്ള കോമാ ഉപയോഗിച്ച് വേർതിരിച്ച ഇ-മെയിൽ വിലാസങ്ങൾ.",
"settings.general.faviconURL": "ഫാവ് ഐക്കൺ യൂ. ആർ. എൽ",
"settings.general.faviconURLHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ഫാവ് ഐക്കണിന്റെ പൂർണ്ണ വെബ് വിലാസം.",
"settings.general.fromEmail": "സ്ഥിരസ്ഥിതി `from` ഇ-മെയിൽ",
"settings.general.fromEmailHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ലോഗോയുടെ പൂർണ്ണ വെബ് വിലാസം.",
"settings.general.language": "ഭാഷ",
"settings.general.logoURL": "ലോഗോ യൂ. ആർ. എൽ",
"settings.general.logoURLHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ലോഗോയുടെ പൂർണ്ണ വെബ് വിലാസം.",
"settings.general.name": "പൊതുവായ",
"settings.general.rootURL": "റൂട്ട് യൂ. ആർ. എൽ",
"settings.general.rootURLHelp": "ഇൻസ്റ്റാളേഷന്റെ പൊതു യൂ. ആർ. എൽ (അവസാനത്തെ സ്ലാഷ് ആവശ്യമില്ല).",
"settings.invalidMessengerName": "സന്ദേശവാഹകന്റെ പേര് അസാധുവാണ്",
"settings.media.provider": "ദാതാവ്",
"settings.media.s3.bucket": "ബക്കറ്റ്",
"settings.media.s3.bucketPath": "ബക്കറ്റിലേക്കുള്ള പാത്ത്",
"settings.media.s3.bucketPathHelp": "ബക്കറ്റിലേക്ക് ഫയൽ അപ്ലോഡ് ചെയ്യാനുള്ള പാത്ത്. സ്ഥിരസ്ഥിതി / ആണ്",
"settings.media.s3.bucketType": "ബക്കറ്റ് തരം",
"settings.media.s3.bucketTypePrivate": "സ്വകാര്യമായ",
"settings.media.s3.bucketTypePublic": "പൊതുവായ",
"settings.media.s3.key": "AWS പ്രവേശന വാക്യം",
"settings.media.s3.region": "മേഘല",
"settings.media.s3.secret": "AWS പ്രവേശന രഹസ്യം",
"settings.media.s3.uploadExpiry": "അപ്ലോഡിന്റെ കാലാവധി",
"settings.media.s3.uploadExpiryHelp": "(ഐച്ഛികം) മുൻകൂട്ടി നിർമ്മിക്കുന്ന യൂ. ആർ. എല്ലിനുള്ള സെക്കന്റിലുള്ള TTL വ്യക്തമാക്കുക . സ്വകാര്യ ബക്കറ്റുകൾക്ക് മാത്രമേ ബാധകമാകൂ (s, m, h, d എന്നിവ യഥാക്രമം സെക്കന്റ്, മിനുട്ട്, മണിക്കൂർ, ദിവസങ്ങൾ എന്നിവയെ സൂചിപ്പിക്കുന്നു).",
"settings.media.title": "മീഡിയാ അപ്ലോഡുകൾ",
"settings.media.upload.path": "അപ്ലോഡ് പാത്ത്",
"settings.media.upload.pathHelp": "മീഡിയ അപ്ലോഡ് ചെയ്യുന്നതിനുള്ള ഡയറക്ടറിയിലേക്കുള്ള പാത്ത്.",
"settings.media.upload.uri": "അപ്ലോഡ് യൂ. ആർ. ഐ",
"settings.media.upload.uriHelp": "അപ്ലോഡ് യൂ. ആർ. ഐ പൊതുവായി ദ്രശ്യമായിരിക്കും. `upload_path` ലേക്ക് അപ്ലോഡ് ചെയ്ത മീഡിയകൾ {root_url} ൽ എല്ലാവർക്കും പ്രാപ്യമായിരിക്കും. ഉദാഹരണത്തിന് https://listmonk.yoursite.com/uploads.",
"settings.messengers.maxConns": "പരമാവധി കണക്ഷനുകൾ",
"settings.messengers.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
"settings.messengers.messageDiscard": "മാറ്റങ്ങൾ നിരസിക്കട്ടെ?",
"settings.messengers.messageSaved": "ക്രമീകരണങ്ങൾ സംരക്ഷിച്ചു. ആപ്പ് പുനരാരംഭിക്കുന്നു ...",
"settings.messengers.name": "സന്തേശ വാഹകർ",
"settings.messengers.nameHelp": "ഉദാഹരണം: എന്റെ-ലിസ്റ്റ്. അക്കങ്ങളും അക്ഷരങ്ങളും / ഡാഷും.",
"settings.messengers.password": "രഹസ്യ വാക്ക്",
"settings.messengers.retries": "പുനഃശ്രമങ്ങൾ",
"settings.messengers.retriesHelp": "സന്ദേശമയക്കാൻ ശ്രമിച്ച് പരാജയപ്പെട്ടാൽ എത്ര തവണ വീണ്ടും ശ്രമിക്കണം.",
"settings.messengers.skipTLSHelp": "TLS സർട്ടിഫിക്കേറ്റിന്റെ ഹോസ്റ്റ്നേയിം പരിശോധന ഒഴിവാക്കുക.",
"settings.messengers.timeout": "നിഷ്‌ക്രിയതാ സമയപരിധി",
"settings.messengers.timeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
"settings.messengers.url": "യൂ. ആർ. എൽ",
"settings.messengers.urlHelp": "പോസ്റ്റ്ബാക്ക് സേർവറിന്റെ റൂട്ട് യൂ. ആർ. എൽ.",
"settings.messengers.username": "ഉപഭോക്ത്ര നാമം",
"settings.performance.batchSize": "ബാച്ചിന്റെ വലിപ്പം",
"settings.performance.batchSizeHelp": "ഒരാവർത്തനത്തിൽ എത്ര വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കണം. ഓരോ തവണയും വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കുകയും അടുത്ത ആവർത്തനത്തിൽ അടുത്ത ബാച്ചിനെ എടുക്കുകയും അങ്ങനെ തുടരുകയും ചെയ്യും. ഈ മൂല്യം പരമാവധി ത്രൂപുട്ടിനേക്കാളും (concurrency * message_rate) കൂടുതലാകുന്നതാണ് നല്ലത്.",
"settings.performance.concurrency": "കൺകറൻസി",
"settings.performance.concurrencyHelp": "ഒരുമിച്ച് സന്ദേശമയക്കാൻ ശ്രമിക്കുന്നതിനുള്ള പരമാവധി സമാന്തര ജോലിക്കാർ (ത്രെഡുകൾ).",
"settings.performance.maxErrThreshold": "പിശകുണ്ടാകാവുന്നതിന്റെ പരമാവധി പരിധി",
"settings.performance.maxErrThresholdHelp": "ഒരു ക്യാമ്പേയ്ൻ ഓടിക്കുമ്പോൾ സ്വമേധയാലുള്ള അന്വേഷണം അല്ലെങ്കിൽ ഇടപെടലിനു മുമ്പ് സഹിക്കാൻ കഴിയുന്ന പരമാവധി പിശകുകളുടെ (ഉദാഹരണത്തിന് ഇ-മെയിലയക്കുമ്പോളുണ്ടായേക്കാവുന്ന SMTP സമയപരിധീ പ്രശ്നങ്ങൾ). 0 ആണെങ്കിൽ ഒരിക്കലും താൽക്കാലികമായി നിർത്തില്ല.",
"settings.performance.messageRate": "സന്തേശത്തിന്റെ നിരക്ക്",
"settings.performance.messageRateHelp": "ഒരു ജോലിക്കാരൻ ഒരു സെക്കന്റിൽ അയക്കേണ്ട പരമാവധി സന്ദേശങ്ങൾ. സമാന്തരമായി അയക്കുന്നത് 10ു സന്ദേശത്തിന്റെ തോത് 10ു ആണെങ്കിൽ ഒരു സെക്കന്റിൽ 10x10 = 100 സന്ദേശങ്ങൾ അയച്ചേക്കാം. ലക്ഷ്യം വെകക്കുന്ന സേർവർ തോത് നിയന്ത്രിക്കുന്നുണ്ടെങ്കിൽ ഈ മൂല്യം മെച്ചപ്പെടുത്തേണ്ടതാണ്.",
"settings.performance.name": "പെർഫോമൻസ്",
"settings.performance.slidingWindow": "സ്ലൈഡിങ് വിൻഡോ പരിധി പ്രവർത്തനക്ഷമമാക്കുക",
"settings.performance.slidingWindowDuration": "ദൈർഘ്യം",
"settings.performance.slidingWindowDurationHelp": "സ്ലൈഡിങ് വിൻഡോയുടെ കാലയളവിന്റെ ദൈർഘ്യം (മിനുട്ടിന് m, മണിക്കൂറിന് h)",
"settings.performance.slidingWindowHelp": "നൽകിയ കാലയളവിൽ അയച്ച സന്ദേശങ്ങളുടെ ആകെ എണ്ണം പരിമിതപ്പെടുത്തുക. ഈ പരിധിയിലെത്തുമ്പോൾ, സമയ വിൻഡോ കഴിയുന്നതുവരെ സന്ദേശങ്ങൾ അയയ്‌ക്കുന്നത് നിർത്തിവെക്കുക.",
"settings.performance.slidingWindowRate": "പരമാവധി സന്ദേശങ്ങൾ",
"settings.performance.slidingWindowRateHelp": "വിൻഡോ ദൈർഘ്യത്തിനുള്ളിൽ അയക്കേണ്ട പരമാവധി സന്ദേശങ്ങളുടെ എണ്ണം",
"settings.privacy.allowBlocklist": "തടയുന്ന പട്ടിക അനുവദിക്കുക",
"settings.privacy.allowBlocklistHelp": "എല്ലാ മെയിലിങ് ലിസ്റ്റുകളിൽ നിന്നും വരിക്കാരല്ലാതാകാനും തടയുന്ന പട്ടികയിൽപ്പെടുത്താനും ഉപഭോക്താക്കളെ അനുവദിക്കണോ?",
"settings.privacy.allowExport": "എക്സ്പോർട്ട് ചെയ്യാനനുവദിക്കുക",
"settings.privacy.allowExportHelp": "ഉപഭോക്കാക്കളിൽ നിന്നും ശേഖരിച്ച വിവരങ്ങൾ എക്സ്പോർട്ട് ചെയ്യാൻ അനുവദിക്കണോ?",
"settings.privacy.allowWipe": "വിവരങ്ങൾ എന്നന്നേയ്ക്കുമായി ഇല്ലാതാക്കുന്നത് അനുവദിക്കുക",
"settings.privacy.allowWipeHelp": "ഉപഭോക്താക്കളെ അവരുടെ വരിക്കാരായിട്ടുള്ള ലിസ്റ്റുകളും മറ്റു വിവരങ്ങളും ഡാറ്റാബേസിൽ നിന്നും ഇല്ലാതാക്കാൻ അനുവദിക്കുക.ക്യാമ്പെയ്ൻ കാഴ്ചകളും കണ്ണികളിന്മേലുള്ള ക്ലിക്കുകളുടെ വിവരങ്ങളും ഇല്ലാതാക്കുമെങ്കിലും കാഴ്ചകളുടെയും കണ്ണിയിലുള്ള ക്ലിക്കുകളുടെ (ഉപഭോക്തൃ വിവരങ്ങളില്ലാതെ) എണ്ണവും നിലനിൽക്കും. അതിനാൽ സ്ഥിതിവിവരക്കണക്കുകളെയും വിശകലനങ്ങളെയും ബാധിക്കില്ല.",
"settings.privacy.individualSubTracking": "വ്യക്തിഗത വരിക്കാരെ പിൻതുടരുക",
"settings.privacy.individualSubTrackingHelp": "ഉപഭോക്തൃ തലത്തിലുള്ള ക്യാമ്പെയ്ൻ കാഴ്ചകളും കണ്ണിയിലെ ക്ലിക്കുകളും പിൻതുടരുക. അപ്രാപ്‌തമാക്കിയാൽ ക്യാമ്പെയ്ൻ കാഴ്ചകളും കണ്ണികളിന്മേലുള്ള ക്ലിക്കുകളുടെ വിവരങ്ങളും രേഖപ്പെടുത്തുമെങ്കുലും ഉപഭോക്താക്കളുടെ വിവരങ്ങളോട് ചേർക്കില്ല.",
"settings.privacy.listUnsubHeader": "`List-Unsubscribe` തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക",
"settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.",
"settings.privacy.name": "സ്വകാര്യത",
"settings.smtp.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ",
"settings.smtp.customHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ",
"settings.smtp.customHeadersHelp": "ഈ സേർവറിൽ നിന്നും അയക്കുന്ന എല്ലാ ഈ-മെയിലിലും ഉണ്ടാകേണ്ട ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ. ഉദാഹരണം: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "പ്രവർത്തനക്ഷമമാക്കി",
"settings.smtp.heloHost": "HELO ഹോസ്റ്റ് നേയിം",
"settings.smtp.heloHostHelp": "ഐച്ഛികമാണ്. ചില എസ്. എം. ടീ. പി സേർവ്വറുകൾക്ക് ഹോസ്റ്റ് നേയിമിൽ FQDN വേണ്ടിവരാം. HELLO യ്ക്ക് `localhost` ഉപയോഗിക്കും. ഹോസ്റ്റ് നേയിം ഇഷ്ടാനുസൃതമാക്കാൻ ഇത് സജ്ജമാക്കുക",
"settings.smtp.host": "ഹോസ്റ്റ്",
"settings.smtp.hostHelp": "എസ്. എം. ടീ. പി സേർവ്വറിന്റെ വിലാസം.",
"settings.smtp.idleTimeout": "നിഷ്‌ക്രിയതാ സമയപരിധി",
"settings.smtp.idleTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
"settings.smtp.maxConns": "പരമാവധി കണക്ഷനുകൾ",
"settings.smtp.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
"settings.smtp.name": "എസ്. എം. ടീ. പി",
"settings.smtp.password": "രഹസ്യ വാക്ക്",
"settings.smtp.passwordHelp": "മാറ്റം വരുത്താൻ എന്റർ കീ അമർത്തുക",
"settings.smtp.port": "പോർട്ട്",
"settings.smtp.portHelp": "എസ്. എം. ടീ. പി സേർവറിന്റെ പോർട്ട്.",
"settings.smtp.retries": "പുനഃശ്രമങ്ങൾ",
"settings.smtp.retriesHelp": "സന്ദേശമയ്ക്കുന്നത് പരാജയപ്പെട്ടാൽ എത്ര തവണ വീണ്ടും ശ്രമിക്കണം.",
"settings.smtp.setCustomHeaders": "ഇഷ്‌ടാനുസൃത തലക്കെട്ടുകൾ നൽകുക",
"settings.smtp.skipTLS": "TLS പരിശോധന ഒഴിവാക്കുക",
"settings.smtp.skipTLSHelp": "TLS സർട്ടിഫിക്കേറ്റിന്റെ ഹോസ്റ്റ്നേയിം പരിശോധന ഒഴിവാക്കുക.",
"settings.smtp.tls": "ടിഎൽഎസ്",
"settings.smtp.tlsHelp": "STARTTLS പ്രവർത്തനക്ഷമമാക്കുക.",
"settings.smtp.username": "ഉപഭോക്തൃ നാമം",
"settings.smtp.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി",
"settings.smtp.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
"settings.title": "ക്രമീകരണങ്ങൾ",
"subscribers.advancedQuery": "വിപുലമായത്",
"subscribers.advancedQueryHelp": "വരിക്കാരുടെ വിവരങ്ങൾ മനസിലാക്കുന്നതിനായുള്ള ഭാഗികമായ SQL പ്രയേഗം",
"subscribers.attribs": "ആട്രിബ്യൂട്ടുകൾ",
"subscribers.attribsHelp": "ജേസൺ മാപ്പായി ആട്രിബ്യൂട്ടുകൾ നിർവ്വചിക്കുക. ഉദാഹരണത്തിന്:",
"subscribers.blocklistedHelp": "തടയുന്ന പട്ടികയിലുള്ള വരിക്കാർക്ക് ഇ-മെയിലുകളൊന്നും അയക്കില്ല. | തടയുന്ന പട്ടികയിലുള്ള വരിക്കാർ ഇ-മെയിലുകളൊന്നും സ്വീകരിക്കില്ല",
"subscribers.confirmBlocklist": "വരിക്കാരനെ തടയുന്ന പട്ടികയിൽ ചേർക്കട്ടേ? | {num} വരിക്കാരേ തടയുന്ന പട്ടികയിൽ ചേർക്കട്ടേ?",
"subscribers.confirmDelete": "വരിക്കാരനെ ഇല്ലാതാക്കട്ടെ? | {num} വരിക്കാരേ ഇല്ലാതാക്കട്ടെ?",
"subscribers.confirmExport": "വരിക്കാരനെ എക്സ്പോർട്ട് ചെയ്യട്ടേ? | {num} വരിക്കാരെ എക്സ്പോർട്ട് ചെയ്യട്ടേ?",
"subscribers.downloadData": "ഡാറ്റ ഡൗൺലോഡുചെയ്യുക",
"subscribers.email": "ഇ-മെയിൽ",
"subscribers.emailExists": "ഇ-മെയിൽ നേരത്തേതന്നെ ഉള്ളതാണ്",
"subscribers.errorBlocklisting": "വരിക്കാരെ തടയുന്ന പട്ടികയിൽ പെടുത്തുന്നതിൽ പരാജയപ്പേട്ടു: {error}",
"subscribers.errorInvalidIDs": "നൽകിയിരിക്കുന്ന ഐഡികളിൽ ഒന്നോ അതിലധികം അസാധുവാണ്: {error}",
"subscribers.errorNoIDs": "ഐഡികളൊന്നും നൽകിയിട്ടില്ല",
"subscribers.errorNoListsGiven": "ലിസ്റ്റുകളോന്നും നൽകിയിട്ടില്ല",
"subscribers.errorPreparingQuery": "വരിക്കാരന്റെ ചോദ്യം തയാറാക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
"subscribers.errorSendingOptin": "ഓപ്റ്റ്-ഇൻ ഇ-മെയിൽ അയക്കുന്നത് പരാജയപ്പെട്ടു",
"subscribers.export": "എക്സ്പോർട്ട്",
"subscribers.invalidAction": "നടപടി അസാധുവാണ്",
"subscribers.invalidEmail": "ഇ-മെയിൽ അസാധുവാണ്",
"subscribers.invalidJSON": "ആട്രിബ്യൂട്ടുകളിലെ ജേസൺ അസാധുവാണ്",
"subscribers.invalidName": "പേര് അസാധുവാണ്",
"subscribers.listChangeApplied": "വരുത്തിയ മാറ്റങ്ങൾ കാണിയ്ക്കുക",
"subscribers.lists": "ലിസ്റ്റുകൾ",
"subscribers.listsHelp": "സ്വമേധയാ വരിക്കാരല്ലാതായവരെ ലിസ്റ്റിൽനിന്നും നീക്കം ചെയ്യാനാകില്ല.",
"subscribers.listsPlaceholder": "വരിക്കാരൻ അംഗമായ ലിസ്റ്റുകൾ",
"subscribers.manageLists": "ലിസ്റ്റ് കൈകാര്യം ചെയ്യുക",
"subscribers.markUnsubscribed": "വരിക്കാരനല്ലെന്ന് അടയാളപ്പെടുത്തുക",
"subscribers.newSubscriber": "പുതിയ വരിക്കാരൻ",
"subscribers.numSelected": "വരിക്കാരനെ തിരഞ്ഞെടുത്തു | {num} വരിക്കാരെ തിരഞ്ഞെടുത്തു",
"subscribers.optinSubject": "വരിക്കാരനാകുന്നത് തീർപ്പാക്കുക",
"subscribers.query": "ചോദ്യം",
"subscribers.queryPlaceholder": "പേരോ ഇ-മെയിൽ വിലാസമോ",
"subscribers.reset": "പുനഃസജ്ജമാക്കുക",
"subscribers.selectAll": "{num} എല്ലാം തിരഞ്ഞടുക്കുക",
"subscribers.status.blocklisted": "തടയുന്ന പട്ടികയിൽ ചേർത്തു",
"subscribers.status.enabled": "പ്രവർത്തനക്ഷമാക്കി",
"subscribers.status.subscribed": "വരിക്കാരനായി",
"subscribers.status.unconfirmed": "തീർച്ചപ്പെടുത്താത്തത്",
"subscribers.status.unsubscribed": "വരിക്കാരനല്ലാതായി",
"subscribers.subscribersDeleted": "വരിക്കാരനെ നീക്കം ചെയ്തു | {num} വരിക്കാരെ നീക്കം ചെയ്തു",
"templates.cantDeleteDefault": "സ്ഥിരസ്ഥിതിയിലുള്ള ടെംപ്ലേറ്റ് നീക്കം ചെയ്യാനാകില്ല",
"templates.default": "സ്ഥിരസ്ഥിതി",
"templates.dummyName": "ഡമ്മി ക്യാമ്പേയ്ൻ",
"templates.dummySubject": "ഡമ്മി ക്യാമ്പേയ്ന്റെ വിഷയം",
"templates.errorCompiling": "ടെംപ്ലേറ്റ് സംഗ്രഹിക്കുന്നതിൽ പിഴവുണ്ടായി: {error}",
"templates.errorRendering": "ടെംപ്ലേറ്റ് ചിത്രീകരിയ്ക്കുന്നതിൽ പിഴവുണ്ടായി: {error}",
"templates.fieldInvalidName": "`name` ന്റെ ദൈർഘ്യം അസാധുവാണ്.",
"templates.makeDefault": "സ്ഥിരസ്ഥിതിയിലുള്ളതാക്കുക",
"templates.newTemplate": "പുതിയ ടെംപ്ലേറ്റ്",
"templates.placeholderHelp": "{placeholder} എന്ന പ്ലെയ്‌സ്‌ഹോൾഡർ ടെംപ്ലേറ്റിൽ ഒരിക്കലെങ്കിലും വരണം.",
"templates.preview": "പ്രിവ്യൂ",
"templates.rawHTML": "എച്. ടീ. എം. എൽ"
}

431
i18n/pt.json Normal file
View File

@ -0,0 +1,431 @@
{
"_.code": "pt",
"_.name": "Portuguese (pt)",
"admin.errorMarshallingConfig": "Erro ao ler o config: {error}",
"campaigns.addAltText": "Adicionar mensagem alternativa em texto simples",
"campaigns.cantUpdate": "Não é possível atualizar uma campanha em curso ou terminada.",
"campaigns.clicks": "Cliques",
"campaigns.confirmDelete": "Eliminar {name}",
"campaigns.confirmSchedule": "A campanha irá começar automaticamente na data e hora agendadas. Agendar agora?",
"campaigns.confirmSwitchFormat": "O conteúdo pode perder a formatação. Continuar?",
"campaigns.content": "Conteúdo",
"campaigns.contentHelp": "Conteúdo aqui",
"campaigns.continue": "Continuar",
"campaigns.copyOf": "Cópia de {name}",
"campaigns.dateAndTime": "Dia e hora",
"campaigns.ended": "Terminada",
"campaigns.errorSendTest": "Erro ao enviar teste: {error}",
"campaigns.fieldInvalidBody": "Erro ao compilar corpo da campanha: {error}",
"campaigns.fieldInvalidFromEmail": "`from_email` inválido.",
"campaigns.fieldInvalidListIDs": "Lista de IDs inválida.",
"campaigns.fieldInvalidMessenger": "Mensageiro {name} desconhecido.",
"campaigns.fieldInvalidName": "Tamanho de nome inválido.",
"campaigns.fieldInvalidSendAt": "Data agendada deve ser no futuro.",
"campaigns.fieldInvalidSubject": "Tamanho de corpo inválido.",
"campaigns.fromAddress": "Endereço do Remetente",
"campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
"campaigns.invalid": "Campanha inválida",
"campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
"campaigns.newCampaign": "Nova campanha",
"campaigns.noKnownSubsToTest": "Não existem subscritores para testar.",
"campaigns.noOptinLists": "Não foram encontradas listas opt-in para criar a campanha.",
"campaigns.noSubs": "Não existem subscritores nas listas selecionadas para criar a campanha.",
"campaigns.noSubsToTest": "Não existem subscritores para usar.",
"campaigns.notFound": "Campanha não encontrada.",
"campaigns.onlyActiveCancel": "Apenas campanhas ativas podem ser canceladas.",
"campaigns.onlyActivePause": "Apenas campanhas ativas podem ser pausadas.",
"campaigns.onlyDraftAsScheduled": "Apenas rascunhos de campanhas podem ser agendadas.",
"campaigns.onlyPausedDraft": "Apenas campanhas pausadas e rascunhos podem ser iniciadas.",
"campaigns.onlyScheduledAsDraft": "Apenas campanhas agendadas podem ser guardadas como rascunhos.",
"campaigns.pause": "Pausar",
"campaigns.plainText": "Texto simples",
"campaigns.preview": "Pré-visualizar",
"campaigns.progress": "Progresso",
"campaigns.queryPlaceholder": "Nome ou assunto",
"campaigns.rawHTML": "HTML simples",
"campaigns.removeAltText": "Remover mensagem alternativa em texto simples",
"campaigns.richText": "Texto rico",
"campaigns.schedule": "Agendar campanha",
"campaigns.scheduled": "Agendada",
"campaigns.send": "Enviar",
"campaigns.sendLater": "Enviar mais tarde",
"campaigns.sendTest": "Enviar mensagem de teste",
"campaigns.sendTestHelp": "Clica Enter após escrever o endereço de múltiplos destinatários. Os endereços devem pertencer a subscritores existentes.",
"campaigns.sendToLists": "Listas a enviar para",
"campaigns.sent": "Enviada",
"campaigns.start": "Começar campanha",
"campaigns.started": "\"{name}\" começou",
"campaigns.startedAt": "Começou",
"campaigns.stats": "Estatísticas",
"campaigns.status.cancelled": "Cancelada",
"campaigns.status.draft": "Rascunho",
"campaigns.status.finished": "Terminada",
"campaigns.status.paused": "Em Pausa",
"campaigns.status.running": "Em progresso",
"campaigns.status.scheduled": "Agendada",
"campaigns.statusChanged": "\"{name}\" está {status}",
"campaigns.subject": "Assunto",
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Mensagem de teste enviada",
"campaigns.timestamps": "Carimbo de hora",
"campaigns.views": "Visualizações",
"dashboard.campaignViews": "Vista de campanhas",
"dashboard.linkClicks": "Cliques nos links",
"dashboard.messagesSent": "Mensagens enviadas",
"dashboard.orphanSubs": "Órfãos",
"email.data.info": "Uma cópia de todos os seus dados está em anexo em formato JSON. Pode ser visualizada num editor de texto.",
"email.data.title": "Os seus dados",
"email.optin.confirmSub": "Confirmar subscrição",
"email.optin.confirmSubHelp": "Confirme a sua subscrição clicando no botão abaixo.",
"email.optin.confirmSubInfo": "Foi adicionado às seguintes listas:",
"email.optin.confirmSubTitle": "Confirmar subscrição",
"email.optin.confirmSubWelcome": "Olá {name},",
"email.optin.privateList": "Lista privada",
"email.status.campaignReason": "Motivo",
"email.status.campaignSent": "Enviada",
"email.status.campaignUpdateTitle": "Atualização de campanha",
"email.status.importFile": "Ficheiro",
"email.status.importRecords": "Registos",
"email.status.importTitle": "Importar atualização",
"email.status.status": "Estado",
"email.unsub": "Cancelar subscrição",
"email.unsubHelp": "Não quer receber estes e-mails?",
"forms.formHTML": "Formulário HTML",
"forms.formHTMLHelp": "Usa o seguinte código HTML para mostrar um formulário de subscrição numa página externa. O formulário deve ter um campo de email e um ou mais campos `l` (UUID de listas). O campo de nome é opcional.",
"forms.publicLists": "Listas públicas",
"forms.publicSubPage": "Página pública de subscrição",
"forms.selectHelp": "Seleciona listas para adicionar ao formulário.",
"forms.title": "Formulários",
"globals.buttons.add": "Adicionar",
"globals.buttons.addNew": "Adicionar novo",
"globals.buttons.cancel": "Cancelar",
"globals.buttons.clone": "Duplicar",
"globals.buttons.close": "Fechar",
"globals.buttons.continue": "Continuar",
"globals.buttons.delete": "Eliminar",
"globals.buttons.edit": "Editar",
"globals.buttons.enabled": "Ativo",
"globals.buttons.learnMore": "Saber mais",
"globals.buttons.new": "Novo",
"globals.buttons.ok": "Ok",
"globals.buttons.remove": "Remover",
"globals.buttons.save": "Guardar",
"globals.buttons.saveChanges": "Guardar alterações",
"globals.days.1": "Seg",
"globals.days.2": "Ter",
"globals.days.3": "Qua",
"globals.days.4": "Qui",
"globals.days.5": "Sex",
"globals.days.6": "Sáb",
"globals.days.7": "Dom",
"globals.fields.createdAt": "Criado a",
"globals.fields.id": "ID",
"globals.fields.name": "Nome",
"globals.fields.status": "Estado",
"globals.fields.type": "Tipo",
"globals.fields.updatedAt": "Atualizado a",
"globals.fields.uuid": "UUID",
"globals.messages.confirm": "Tens a certeza?",
"globals.messages.created": "\"{name}\" criado",
"globals.messages.deleted": "\"{name}\" eliminado",
"globals.messages.emptyState": "Não há nada aqui",
"globals.messages.errorCreating": "Erro ao criar {name}: {error}",
"globals.messages.errorDeleting": "Erro ao eliminar {name}: {error}",
"globals.messages.errorFetching": "Erro ao carregar {name}: {error}",
"globals.messages.errorUUID": "Erro ao gerar UUID: {error}",
"globals.messages.errorUpdating": "Erro ao atualizar {name}: {error}",
"globals.messages.invalidID": "ID inválido",
"globals.messages.invalidUUID": "UUID inválido",
"globals.messages.notFound": "{name} não encontrado",
"globals.messages.passwordChange": "Insere um valor para alterar",
"globals.messages.updated": "\"{name}\" atualizado",
"globals.months.1": "Jan",
"globals.months.10": "Out",
"globals.months.11": "Nov",
"globals.months.12": "Dez",
"globals.months.2": "Fev",
"globals.months.3": "Mar",
"globals.months.4": "Abr",
"globals.months.5": "Mai",
"globals.months.6": "Jun",
"globals.months.7": "Jul",
"globals.months.8": "Ago",
"globals.months.9": "Set",
"globals.terms.campaign": "Campanha | Campanhas",
"globals.terms.campaigns": "Campanha",
"globals.terms.dashboard": "Dashboard",
"globals.terms.list": "Lista | Listas",
"globals.terms.lists": "Listas",
"globals.terms.media": "Mídia | Mídia",
"globals.terms.messenger": "Mensageiro | Mensageiros",
"globals.terms.messengers": "Mensageiros",
"globals.terms.settings": "Definições",
"globals.terms.subscriber": "Subscritor | Subcritores",
"globals.terms.subscribers": "Subscritores",
"globals.terms.tag": "Etiqueta | Etiquetas",
"globals.terms.tags": "Etiquetas",
"globals.terms.template": "Modelo | Modelos",
"globals.terms.templates": "Modelo",
"import.alreadyRunning": "Uma importação já está em curso. Aguarda que termine ou cancela-a antes de tentares novamente.",
"import.blocklist": "Lista de bloqueio",
"import.csvDelim": "Delimitador CSV",
"import.csvDelimHelp": "O delimitador padrão é uma vírgula.",
"import.csvExample": "Exemplo CSV simples",
"import.csvFile": "Ficheiro CSV ou ZIP",
"import.csvFileHelp": "Clica ou arrasta um ficheiro CSV ou ZIP para aqui",
"import.errorCopyingFile": "Erro ao copiar ficheiro: {error}",
"import.errorProcessingZIP": "Erro ao processar ficheiro ZIP: {error}",
"import.errorStarting": "Erro ao começar importação: {error}",
"import.importDone": "Terminado",
"import.importStarted": "Importação iniciada",
"import.instructions": "Instruções",
"import.instructionsHelp": "Envia um ficheiro CSV ou ficheiro ZIP com um único CSV para importares subscritores em massa. O ficheiro CSV deve conter os seguintes cabeçalhos com os nomes de colunas exatos. attributes (opcional) deve ser uma string JSON válida, com aspas de escape duplo.",
"import.invalidDelim": "O delimitador deve ser um caractere único.",
"import.invalidFile": "Ficheiro inválido: {error}",
"import.invalidMode": "Modo inválido",
"import.invalidParams": "Parâmetros inválidos: {error}",
"import.listSubHelp": "Listas a subscrever.",
"import.mode": "Modo",
"import.overwrite": "Sobrescrever?",
"import.overwriteHelp": "Sobrescrever nome e atributos de subscritores existentes?",
"import.recordsCount": "{num} / {total} registos",
"import.stopImport": "Parar importação",
"import.subscribe": "Subscrever",
"import.title": "Importar subscritores",
"import.upload": "Upload",
"lists.confirmDelete": "Tens a certeza? Isto não elimina subscritores.",
"lists.confirmSub": "Confirmar subscrição(ões) para {name}",
"lists.invalidName": "Nome inválido",
"lists.newList": "Nova lista",
"lists.optin": "Opt-in",
"lists.optinHelp": "Double opt-in envia um email ao subscritor a pedir confirmação. Em listas double opt-in, as campanhas são apenas enviadas para subscritores confirmados.",
"lists.optinTo": "Opt-in a {name}",
"lists.optins.double": "Double opt-in",
"lists.optins.single": "Single opt-in",
"lists.sendCampaign": "Enviar campanha",
"lists.sendOptinCampaign": "Enviada campanha opt-in",
"lists.type": "Tipo",
"lists.typeHelp": "Listas públicas estão abertas para toda a gente se subscrever e os seus nomes podem aparecer em páginas públicas, como a página de gestão de subscrições.",
"lists.types.private": "Privado",
"lists.types.public": "Público",
"logs.title": "Logs (Histórico)",
"media.errorReadingFile": "Erro ao ler ficheiro: {error}",
"media.errorResizing": "Erro ao alterar tamanho da imagem: {error}",
"media.errorSavingThumbnail": "Erro ao guardar miniatura: {error}",
"media.errorUploading": "Erro ao enviar ficheiro: {error}",
"media.invalidFile": "Ficheiro inválido: {error}",
"media.title": "Mídia",
"media.unsupportedFileType": "Tipo de ficheiro não suportado ({type})",
"media.upload": "Upload",
"media.uploadHelp": "Clica ou arrasta uma ou mais imagens aqui",
"media.uploadImage": "Enviar imagens",
"menu.allCampaigns": "Todas as campanhas",
"menu.allLists": "Todas as listas",
"menu.allSubscribers": "Todos os subscritores",
"menu.dashboard": "Dashboard",
"menu.forms": "Formulários",
"menu.import": "Importar",
"menu.logs": "Logs",
"menu.media": "Mídia",
"menu.newCampaign": "Criar nova",
"menu.settings": "Definições",
"public.campaignNotFound": "A mensagem de email não foi encontrada.",
"public.confirmOptinSubTitle": "Confirmar subscrição",
"public.confirmSub": "Confirmar subscrição",
"public.confirmSubInfo": "Foi adicionado às seguintes listas:",
"public.confirmSubTitle": "Confirmar",
"public.dataRemoved": "As suas subscrições e todos os dados associados foram removidos.",
"public.dataRemovedTitle": "Dados removidos",
"public.dataSent": "Os seus dados foram-lhe enviados em anexo por email.",
"public.dataSentTitle": "Dados enviados por email",
"public.errorFetchingCampaign": "Error fetching e-mail message",
"public.errorFetchingEmail": "Mensagem de email não encontrada",
"public.errorFetchingLists": "Erro ao carregar listas. Por favor tente novamente.",
"public.errorProcessingRequest": "Erro ao processar pedido. Por favor tente novamente.",
"public.errorTitle": "Erro",
"public.invalidFeature": "That feature is not available",
"public.invalidLink": "Link inválido",
"public.noListsAvailable": "Não existem listas disponíveis para subscrever.",
"public.noListsSelected": "Não foram selecionadas listas válidas para subscrever.",
"public.noSubInfo": "There are no subscriptions to confirm",
"public.noSubTitle": "Sem subscrições",
"public.notFoundTitle": "Não encontrado",
"public.privacyConfirmWipe": "Tem a certeza que deseja apagar permanentemente todos os seus dados de subscrições?",
"public.privacyExport": "Exportar os seus dados",
"public.privacyExportHelp": "Uma cópia dos seus dados ser-lhe-á enviada por email.",
"public.privacyTitle": "Privacidade e dados",
"public.privacyWipe": "Apagar os seus dados",
"public.privacyWipeHelp": "Apagar permanentemente da base de dados todas as suas subscrições e dados relacionados.",
"public.sub": "Subscrever",
"public.subConfirmed": "Subscribed successfully",
"public.subConfirmedTitle": "Confirmado",
"public.subName": "Nome (opcional)",
"public.subNotFound": "Subscrição não encontrada.",
"public.subPrivateList": "Lista privada",
"public.subTitle": "Subscrever",
"public.unsub": "Cancelar subscrição",
"public.unsubFull": "Também cancelar subscrição de todos os emails futuros.",
"public.unsubHelp": "Quer cancelar a subscrição desta lista de emails?",
"public.unsubTitle": "Cancelar subscrição",
"public.unsubbedInfo": "A sua subscrição foi cancelada com sucesso.",
"public.unsubbedTitle": "Subscrição cancelada",
"public.unsubscribeTitle": "Cancelar subscrição da lista de emails",
"settings.duplicateMessengerName": "Nome duplicado do mensageiro: {name}",
"settings.errorEncoding": "Erro de definições de codificação: {error}",
"settings.errorNoSMTP": "Pelo menos um bloco SMTP deve estar ativo",
"settings.general.adminNotifEmails": "Emails de notificação de administração",
"settings.general.adminNotifEmailsHelp": "Lista separada por vírgulas dos endereços de email para os quais devem ser enviadas notificações de administração como updates importantes, conclusão de campanhas, falhas, etc.",
"settings.general.enablePublicSubPage": "Ativar página de subscrição pública",
"settings.general.enablePublicSubPageHelp": "Mostrar uma página de subscrição pública com todas as listas públicas para as pessoas se subscreverem.",
"settings.general.faviconURL": "URL do Favicon",
"settings.general.faviconURLHelp": "(Opcional) URL completo do favicon estático para ser mostrado nas janelas do utilizador, como a página de cancelamento de subscrição.",
"settings.general.fromEmail": "Endereço `de` padrão",
"settings.general.fromEmailHelp": "Email `de` padrão para usar em campanhas. Este pode ser alterado por campanha.",
"settings.general.language": "Linguagem",
"settings.general.logoURL": " Root URL",
"settings.general.logoURLHelp": "(Opcional) URL completo do logotipo para ser mostrado nas janelas do utilizador, como a página de cancelamento de subscrição.",
"settings.general.name": "Geral",
"settings.general.rootURL": "URL base",
"settings.general.rootURLHelp": "URL público da instalação (sem barra final).",
"settings.invalidMessengerName": "Nome de mensageiro inválido.",
"settings.media.provider": "Fornecedor",
"settings.media.s3.bucket": "Bucket",
"settings.media.s3.bucketPath": "Caminho do bucket",
"settings.media.s3.bucketPathHelp": "Caminho dentro do bucket para enviar ficheiros. Padrão é /",
"settings.media.s3.bucketType": "Tipo de bucket",
"settings.media.s3.bucketTypePrivate": "Privado",
"settings.media.s3.bucketTypePublic": "Público",
"settings.media.s3.key": "Chave de acesso AWS",
"settings.media.s3.region": "Região",
"settings.media.s3.secret": "Segredo de acesso AWS",
"settings.media.s3.uploadExpiry": "Validade do upload",
"settings.media.s3.uploadExpiryHelp": "(Opcional) Especifica TTL (em segundos) para o URL pré-assinado gerado. Apenas aplicável a buckets privados (s, m, h, d para segundos, minutos, horas e dias).",
"settings.media.title": "Upload de mídia",
"settings.media.upload.path": "Caminho de upload",
"settings.media.upload.pathHelp": "Caminho para a pasta onde será enviada a mídia.",
"settings.media.upload.uri": "URI de envio",
"settings.media.upload.uriHelp": "URI de envio que é visível ao mundo exterior. Toda a mídia enviada para o upload_path será publicamente acessível em {root_url}/{}, por exemplo, https://listmonk.oteusite.com/uploads.",
"settings.messengers.maxConns": "N. Max. Conexões",
"settings.messengers.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor.",
"settings.messengers.messageDiscard": "Descartar alterações?",
"settings.messengers.messageSaved": "Definições guardadas. Recarregando aplicação ...",
"settings.messengers.name": "Mensageiros",
"settings.messengers.nameHelp": "eg: o-meu-sms. Alfanumérico / traço.",
"settings.messengers.password": "Palavra-passe",
"settings.messengers.retries": "Tentativas",
"settings.messengers.retriesHelp": "Número de vezes para tentar novamente quando uma mensagem falha.",
"settings.messengers.skipTLSHelp": "Saltar verificação do hostname no certificado TLS.",
"settings.messengers.timeout": "Tempo limite de inatividade",
"settings.messengers.timeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "URL base do servidor Postback.",
"settings.messengers.username": "Nome de utilizador",
"settings.performance.batchSize": "Tamanho do lote",
"settings.performance.batchSizeHelp": "O número de subscritores para ir buscar à base de dados numa só iteração. Cada iteração vai buscar subscritores à base de dados, envia-lhe mensagens, e depois segue para a nova iteração para ir buscar o lote seguinte. Isto deve idealmente ser maior do que a máxima taxa de transferência alcançável (simultaneidade * taxa de mensagens).",
"settings.performance.concurrency": "Simultaneidade",
"settings.performance.concurrencyHelp": "Número máximo de workers (threads) concurrentes que irão tentar enviar as mensagens simultaneamente.",
"settings.performance.maxErrThreshold": "Limite máximo de erros",
"settings.performance.maxErrThresholdHelp": "O número de erros (eg: timeouts SMTP ao enviar um email) uma campanha em curso pode tolerar antes de ser colocada em pausa para investigação manual ou intervenção. Colocar a 0 para nunca pausar.",
"settings.performance.messageRate": "Taxa de mensagens",
"settings.performance.messageRateHelp": "Número máximo de mensagens para serem enviadas por segundo num worker. Se simultaneidade = 10 e taxa de mensagens = 10, então até 10x10=100 mensagens podem ser enviadas por segundo. Isto, junto com a simultaneidade, deve ser ajustado de forma a manter o número de mensagens a ser enviadas por segundo abaixo do limite máximo do servidor, se existir.",
"settings.performance.name": "Desempenho",
"settings.performance.slidingWindow": "Ativar o limite de janela",
"settings.performance.slidingWindowDuration": "Duração",
"settings.performance.slidingWindowDurationHelp": "Duração do periodo de limite de janela (m para minuto, h para hora).",
"settings.performance.slidingWindowHelp": "Limitar o número total de mensagens que é enviado num determinado periodo. Ao alcançar este limite, as mensagens são impedidas de ser enviadas até ao fim da janela temporária.",
"settings.performance.slidingWindowRate": "Max. mensagens",
"settings.performance.slidingWindowRateHelp": "Número máximo de mensagens para enviar na duração da janela.",
"settings.privacy.allowBlocklist": "Permitir lista de bloqueio",
"settings.privacy.allowBlocklistHelp": "Permitir ao subscritores cancelar a subscrição de todas as listas de emails e marcar-se como bloqueados?",
"settings.privacy.allowExport": "Permitir exportação",
"settings.privacy.allowExportHelp": "Permitir aos subscritores exportar os dados coletados neles mesmos?",
"settings.privacy.allowWipe": "Permitir eliminação de dados",
"settings.privacy.allowWipeHelp": "Permitir aos subscritores eliminar todos os seus dados, incluindo as suas subscrições, da base de dados. Visualizações de campanhas e cliques em links também são removidos enquanto visualizações e contagem de clicks permanecem (sem nenhum subscritor associado) para que as estatísticas não sejam afetadas.",
"settings.privacy.individualSubTracking": "Tracking individual de subscritores",
"settings.privacy.individualSubTrackingHelp": "Track visualizações e clicked ao nível do subscritor. Quando desligado, visualizações e track de clicks continuam, mas sem estarem associadas a nenhum subscritor.",
"settings.privacy.listUnsubHeader": "Incluir header `List-Unsubscribe`",
"settings.privacy.listUnsubHeaderHelp": "Incluir headers de cancelamento de subscrição que permite aos clientes de email permitir ao utilizadores cancelar a subscrição num único clique.",
"settings.privacy.name": "Privacidade",
"settings.smtp.authProtocol": "Protocolo Autenticação",
"settings.smtp.customHeaders": "Headers customizados",
"settings.smtp.customHeadersHelp": "Array opcional de headers de email a incluir em todas as mensagens enviadas deste servidor. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Ativo",
"settings.smtp.heloHost": "Hostname HELO",
"settings.smtp.heloHostHelp": "Opcional. Alguns servidores SMTP necessitam de um FQDN no hostname. Por padrão, HELLOs usam `localhost`. Coloca um hostname customizado se for necessario.",
"settings.smtp.host": "Host",
"settings.smtp.hostHelp": "O endereço host do servidor SMTP",
"settings.smtp.idleTimeout": "Tempo limite de inatividade",
"settings.smtp.idleTimeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
"settings.smtp.maxConns": "N. Max. Conexões",
"settings.smtp.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor SMTP.",
"settings.smtp.name": "SMTP",
"settings.smtp.password": "Palavra-passe",
"settings.smtp.passwordHelp": "Escreve aqui para alterar",
"settings.smtp.port": "Porta",
"settings.smtp.portHelp": "Porta do servidor SMTP",
"settings.smtp.retries": "Tentativas",
"settings.smtp.retriesHelp": "Número de vezes para tentar novamente quando uma mensagem falha.",
"settings.smtp.setCustomHeaders": "Colocar headers customizados",
"settings.smtp.skipTLS": "Saltar verificação TLS",
"settings.smtp.skipTLSHelp": "Saltar verificação do hostname no certificado TLS.",
"settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Ativar STARTTLS.",
"settings.smtp.username": "Nome de utilizador",
"settings.smtp.waitTimeout": "Tempo limite de espera",
"settings.smtp.waitTimeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
"settings.title": "Definições",
"subscribers.advancedQuery": "Avançado",
"subscribers.advancedQueryHelp": "Expressão SQL parcial para consultar atributos de subscritores",
"subscribers.attribs": "Atributos",
"subscribers.attribsHelp": "Atributos estão definidos como uma mapa JSON, por exemplo:",
"subscribers.blocklistedHelp": "Subscritores bloqueados nunca irão receber emails.",
"subscribers.confirmBlocklist": "Adicionar {num} subscritor(es) à lista de bloqueio?",
"subscribers.confirmDelete": "Eliminar {num} subscritor(es)?",
"subscribers.confirmExport": "Exportar {num} subscritor(es)?",
"subscribers.downloadData": "Descarregar dados",
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail já existe.",
"subscribers.errorBlocklisting": "Erro ao bloquear subscritores: {error}",
"subscribers.errorInvalidIDs": "Foram dados um ou mais IDs inválidos: {error}",
"subscribers.errorNoIDs": "Não foram dados IDs.",
"subscribers.errorNoListsGiven": "Não foram dadas listas.",
"subscribers.errorPreparingQuery": "Erro ao preparar query dos subscritores: {error}",
"subscribers.errorSendingOptin": "Erro ao enviar email opt-in.",
"subscribers.export": "Exportar",
"subscribers.invalidAction": "Ação inválida.",
"subscribers.invalidEmail": "Email inválida.",
"subscribers.invalidJSON": "JSON inválido nos atributos.",
"subscribers.invalidName": "Nome inválido.",
"subscribers.listChangeApplied": "Alteração à lista aplicada.",
"subscribers.lists": "Listas",
"subscribers.listsHelp": "Listas nas quais o/a subscritor/a cancelou a sua subscrição não podem ser removidas.",
"subscribers.listsPlaceholder": "Listas a subscrever",
"subscribers.manageLists": "Gerir listas",
"subscribers.markUnsubscribed": "Marcar como não subscrito",
"subscribers.newSubscriber": "Novo subscritor",
"subscribers.numSelected": "{num} subscritor(es) selecionados",
"subscribers.optinSubject": "Confirmar subscrição",
"subscribers.query": "Query",
"subscribers.queryPlaceholder": "E-mail ou nome",
"subscribers.reset": "Repor",
"subscribers.selectAll": "Selecionar todos os {num}",
"subscribers.status.blocklisted": "Bloqueados",
"subscribers.status.confirmed": "Confirmado",
"subscribers.status.enabled": "Ativo",
"subscribers.status.subscribed": "Subscrito",
"subscribers.status.unconfirmed": "Não confirmado",
"subscribers.status.unsubscribed": "Não subscrito",
"subscribers.subscribersDeleted": "{num} subscritor(es) eliminados",
"templates.cantDeleteDefault": "Não é possível eliminar o template padrão",
"templates.default": "Padrão",
"templates.dummyName": "Campanha fictícia",
"templates.dummySubject": "Assunto da campanha fictícia",
"templates.errorCompiling": "Erro ao compilar template: {error}",
"templates.errorRendering": "Erro ao renderizar mensagem: {error}",
"templates.fieldInvalidName": "Tamanho inválido para o nome.",
"templates.makeDefault": "Marcar como padrão",
"templates.newTemplate": "Novo template",
"templates.placeholderHelp": "O placeholder {placeholder} deve aparecer exatamente uma vez no template.",
"templates.preview": "Pré-visualização",
"templates.rawHTML": "HTML Simples"
}

173
internal/i18n/i18n.go Normal file
View File

@ -0,0 +1,173 @@
// i18n is a simple package 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.
package i18n
import (
"encoding/json"
"errors"
"regexp"
"strings"
)
// I18n offers translation functions over a language map.
type I18n 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(b []byte) (*I18n, error) {
var l map[string]string
if err := json.Unmarshal(b, &l); err != nil {
return nil, err
}
code, ok := l["_.code"]
if !ok {
return nil, errors.New("missing _.code field in language file")
}
name, ok := l["_.name"]
if !ok {
return nil, errors.New("missing _.name field in language file")
}
return &I18n{
langMap: l,
code: code,
name: name,
}, nil
}
// Load loads a JSON language map into the instance overwriting
// existing keys that conflict.
func (i *I18n) Load(b []byte) error {
var l map[string]string
if err := json.Unmarshal(b, &l); err != nil {
return err
}
for k, v := range l {
i.langMap[k] = v
}
return nil
}
// Name returns the canonical name of the language.
func (i *I18n) Name() string {
return i.name
}
// Code returns the ISO code of the language.
func (i *I18n) Code() string {
return i.code
}
// JSON returns the languagemap as raw JSON.
func (i *I18n) 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 *I18n) 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}
// 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: Ts("globals.message.notFound",
// "name", "campaigns",
// "error", err)
func (i *I18n) Ts(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 *I18n) 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 *I18n) 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 *I18n) 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 *I18n) subAllParams(s string) string {
if !strings.Contains(s, `{`) {
return s
}
parts := reParam.FindAllStringSubmatch(s, -1)
if len(parts) < 1 {
return s
}
for _, p := range parts {
s = strings.ReplaceAll(s, p[0], i.T(p[1]))
}
return i.subAllParams(s)
}

View File

@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
) )
@ -40,25 +41,32 @@ type DataSource interface {
type Manager struct { type Manager struct {
cfg Config cfg Config
src DataSource src DataSource
i18n *i18n.I18n
messengers map[string]messenger.Messenger messengers map[string]messenger.Messenger
notifCB models.AdminNotifCallback notifCB models.AdminNotifCallback
logger *log.Logger logger *log.Logger
// Campaigns that are currently running. // Campaigns that are currently running.
camps map[int]*models.Campaign camps map[int]*models.Campaign
campsMutex sync.RWMutex campsMut sync.RWMutex
// Links generated using Track() are cached here so as to not query // Links generated using Track() are cached here so as to not query
// the database for the link UUID for every message sent. This has to // the database for the link UUID for every message sent. This has to
// be locked as it may be used externally when previewing campaigns. // be locked as it may be used externally when previewing campaigns.
links map[string]string links map[string]string
linksMutex sync.RWMutex linksMut sync.RWMutex
subFetchQueue chan *models.Campaign subFetchQueue chan *models.Campaign
campMsgQueue chan CampaignMessage campMsgQueue chan CampaignMessage
campMsgErrorQueue chan msgError campMsgErrorQueue chan msgError
campMsgErrorCounts map[int]int campMsgErrorCounts map[int]int
msgQueue chan Message msgQueue chan Message
// Sliding window keeps track of the total number of messages sent in a period
// and on reaching the specified limit, waits until the window is over before
// sending further messages.
slidingWindowNumMsg int
slidingWindowStart time.Time
} }
// CampaignMessage represents an instance of campaign message to be pushed out, // CampaignMessage represents an instance of campaign message to be pushed out,
@ -71,6 +79,7 @@ type CampaignMessage struct {
to string to string
subject string subject string
body []byte body []byte
altBody []byte
unsubURL string unsubURL string
} }
@ -91,6 +100,9 @@ type Config struct {
Concurrency int Concurrency int
MessageRate int MessageRate int
MaxSendErrors int MaxSendErrors int
SlidingWindow bool
SlidingWindowDuration time.Duration
SlidingWindowRate int
RequeueOnError bool RequeueOnError bool
FromEmail string FromEmail string
IndividualTracking bool IndividualTracking bool
@ -108,7 +120,7 @@ type msgError struct {
} }
// New returns a new instance of Mailer. // New returns a new instance of Mailer.
func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, l *log.Logger) *Manager { func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, i *i18n.I18n, l *log.Logger) *Manager {
if cfg.BatchSize < 1 { if cfg.BatchSize < 1 {
cfg.BatchSize = 1000 cfg.BatchSize = 1000
} }
@ -122,6 +134,7 @@ func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, l *log.L
return &Manager{ return &Manager{
cfg: cfg, cfg: cfg,
src: src, src: src,
i18n: i,
notifCB: notifCB, notifCB: notifCB,
logger: l, logger: l,
messengers: make(map[string]messenger.Messenger), messengers: make(map[string]messenger.Messenger),
@ -132,6 +145,7 @@ func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, l *log.L
msgQueue: make(chan Message, cfg.Concurrency), msgQueue: make(chan Message, cfg.Concurrency),
campMsgErrorQueue: make(chan msgError, cfg.MaxSendErrors), campMsgErrorQueue: make(chan msgError, cfg.MaxSendErrors),
campMsgErrorCounts: make(map[int]int), campMsgErrorCounts: make(map[int]int),
slidingWindowStart: time.Now(),
} }
} }
@ -182,8 +196,8 @@ func (m *Manager) HasMessenger(id string) bool {
// HasRunningCampaigns checks if there are any active campaigns. // HasRunningCampaigns checks if there are any active campaigns.
func (m *Manager) HasRunningCampaigns() bool { func (m *Manager) HasRunningCampaigns() bool {
m.campsMutex.Lock() m.campsMut.Lock()
defer m.campsMutex.Unlock() defer m.campsMut.Unlock()
return len(m.camps) > 0 return len(m.camps) > 0
} }
@ -252,6 +266,7 @@ func (m *Manager) messageWorker() {
Subject: msg.subject, Subject: msg.subject,
ContentType: msg.Campaign.ContentType, ContentType: msg.Campaign.ContentType,
Body: msg.body, Body: msg.body,
AltBody: msg.altBody,
Subscriber: msg.Subscriber, Subscriber: msg.Subscriber,
Campaign: msg.Campaign, Campaign: msg.Campaign,
} }
@ -286,6 +301,7 @@ func (m *Manager) messageWorker() {
Subject: msg.Subject, Subject: msg.Subject,
ContentType: msg.ContentType, ContentType: msg.ContentType,
Body: msg.Body, Body: msg.Body,
AltBody: msg.AltBody,
Subscriber: msg.Subscriber, Subscriber: msg.Subscriber,
Campaign: msg.Campaign, Campaign: msg.Campaign,
}) })
@ -334,6 +350,9 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
} }
return time.Now().Format(layout) return time.Now().Format(layout)
}, },
"L": func() *i18n.I18n {
return m.i18n
},
} }
} }
@ -418,28 +437,28 @@ func (m *Manager) addCampaign(c *models.Campaign) error {
} }
// Add the campaign to the active map. // Add the campaign to the active map.
m.campsMutex.Lock() m.campsMut.Lock()
m.camps[c.ID] = c m.camps[c.ID] = c
m.campsMutex.Unlock() m.campsMut.Unlock()
return nil return nil
} }
// getPendingCampaignIDs returns the IDs of campaigns currently being processed. // getPendingCampaignIDs returns the IDs of campaigns currently being processed.
func (m *Manager) getPendingCampaignIDs() []int64 { func (m *Manager) getPendingCampaignIDs() []int64 {
// Needs to return an empty slice in case there are no campaigns. // Needs to return an empty slice in case there are no campaigns.
m.campsMutex.RLock() m.campsMut.RLock()
ids := make([]int64, 0, len(m.camps)) ids := make([]int64, 0, len(m.camps))
for _, c := range m.camps { for _, c := range m.camps {
ids = append(ids, int64(c.ID)) ids = append(ids, int64(c.ID))
} }
m.campsMutex.RUnlock() m.campsMut.RUnlock()
return ids return ids
} }
// nextSubscribers processes the next batch of subscribers in a given campaign. // nextSubscribers processes the next batch of subscribers in a given campaign.
// If returns a bool indicating whether there any subscribers were processed // It returns a bool indicating whether any subscribers were processed
// in the current batch or not. This can happen when all the subscribers // in the current batch or not. A false indicates that all subscribers
// have been processed, or if a campaign has been paused or cancelled abruptly. // have been processed, or that a campaign has been paused or cancelled.
func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, error) { func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, error) {
// Fetch a batch of subscribers. // Fetch a batch of subscribers.
subs, err := m.src.NextSubscribers(c.ID, batchSize) subs, err := m.src.NextSubscribers(c.ID, batchSize)
@ -452,8 +471,14 @@ func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, erro
return false, nil return false, nil
} }
// Is there a sliding window limit configured?
hasSliding := m.cfg.SlidingWindow &&
m.cfg.SlidingWindowRate > 0 &&
m.cfg.SlidingWindowDuration.Seconds() > 1
// Push messages. // Push messages.
for _, s := range subs { for _, s := range subs {
// Send the message.
msg := m.NewCampaignMessage(c, s) msg := m.NewCampaignMessage(c, s)
if err := msg.Render(); err != nil { if err := msg.Render(); err != nil {
m.logger.Printf("error rendering message (%s) (%s): %v", c.Name, s.Email, err) m.logger.Printf("error rendering message (%s) (%s): %v", c.Name, s.Email, err)
@ -463,6 +488,33 @@ func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, erro
// Push the message to the queue while blocking and waiting until // Push the message to the queue while blocking and waiting until
// the queue is drained. // the queue is drained.
m.campMsgQueue <- msg m.campMsgQueue <- msg
// Check if the sliding window is active.
if hasSliding {
diff := time.Now().Sub(m.slidingWindowStart)
// Window has expired. Reset the clock.
if diff >= m.cfg.SlidingWindowDuration {
m.slidingWindowStart = time.Now()
m.slidingWindowNumMsg = 0
continue
}
// Have the messages exceeded the limit?
m.slidingWindowNumMsg++
if m.slidingWindowNumMsg >= m.cfg.SlidingWindowRate {
wait := m.cfg.SlidingWindowDuration - diff
m.logger.Printf("messages exceeded (%d) for the window (%v since %s). Sleeping for %s.",
m.slidingWindowNumMsg,
m.cfg.SlidingWindowDuration,
m.slidingWindowStart.Format(time.RFC822Z),
wait.Round(time.Second)*1)
m.slidingWindowNumMsg = 0
time.Sleep(wait)
}
}
} }
return true, nil return true, nil
@ -470,16 +522,16 @@ func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, erro
// isCampaignProcessing checks if the campaign is bing processed. // isCampaignProcessing checks if the campaign is bing processed.
func (m *Manager) isCampaignProcessing(id int) bool { func (m *Manager) isCampaignProcessing(id int) bool {
m.campsMutex.RLock() m.campsMut.RLock()
_, ok := m.camps[id] _, ok := m.camps[id]
m.campsMutex.RUnlock() m.campsMut.RUnlock()
return ok return ok
} }
func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Campaign, error) { func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Campaign, error) {
m.campsMutex.Lock() m.campsMut.Lock()
delete(m.camps, c.ID) delete(m.camps, c.ID)
m.campsMutex.Unlock() m.campsMut.Unlock()
// A status has been passed. Change the campaign's status // A status has been passed. Change the campaign's status
// without further checks. // without further checks.
@ -516,12 +568,12 @@ func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Ca
// trackLink register a URL and return its UUID to be used in message templates // trackLink register a URL and return its UUID to be used in message templates
// for tracking links. // for tracking links.
func (m *Manager) trackLink(url, campUUID, subUUID string) string { func (m *Manager) trackLink(url, campUUID, subUUID string) string {
m.linksMutex.RLock() m.linksMut.RLock()
if uu, ok := m.links[url]; ok { if uu, ok := m.links[url]; ok {
m.linksMutex.RUnlock() m.linksMut.RUnlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID) return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
} }
m.linksMutex.RUnlock() m.linksMut.RUnlock()
// Register link. // Register link.
uu, err := m.src.CreateLink(url) uu, err := m.src.CreateLink(url)
@ -532,9 +584,9 @@ func (m *Manager) trackLink(url, campUUID, subUUID string) string {
return url return url
} }
m.linksMutex.Lock() m.linksMut.Lock()
m.links[url] = uu m.links[url] = uu
m.linksMutex.Unlock() m.linksMut.Unlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID) return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
} }
@ -569,10 +621,25 @@ func (m *CampaignMessage) Render() error {
out.Reset() out.Reset()
} }
// Compile the main template.
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil { if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
return err return err
} }
m.body = out.Bytes() m.body = out.Bytes()
// Is there an alt body?
if m.Campaign.ContentType != models.CampaignContentTypePlain && m.Campaign.AltBody.Valid {
if m.Campaign.AltBodyTpl != nil {
b := bytes.Buffer{}
if err := m.Campaign.AltBodyTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
return err
}
m.altBody = b.Bytes()
} else {
m.altBody = []byte(m.Campaign.AltBody.String)
}
}
return nil return nil
} }
@ -587,3 +654,10 @@ func (m *CampaignMessage) Body() []byte {
copy(out, m.body) copy(out, m.body)
return out return out
} }
// AltBody returns a copy of the message's alt body.
func (m *CampaignMessage) AltBody() []byte {
out := make([]byte, len(m.altBody))
copy(out, m.altBody)
return out
}

View File

@ -7,7 +7,6 @@ import (
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
"github.com/jaytaylor/html2text"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/smtppool" "github.com/knadh/smtppool"
) )
@ -19,7 +18,6 @@ type Server struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
AuthProtocol string `json:"auth_protocol"` AuthProtocol string `json:"auth_protocol"`
EmailFormat string `json:"email_format"`
TLSEnabled bool `json:"tls_enabled"` TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"` TLSSkipVerify bool `json:"tls_skip_verify"`
EmailHeaders map[string]string `json:"email_headers"` EmailHeaders map[string]string `json:"email_headers"`
@ -114,12 +112,6 @@ func (e *Emailer) Push(m messenger.Message) error {
} }
} }
mtext, err := html2text.FromString(string(m.Body),
html2text.Options{PrettyTables: true})
if err != nil {
return err
}
em := smtppool.Email{ em := smtppool.Email{
From: m.From, From: m.From,
To: m.To, To: m.To,
@ -140,14 +132,14 @@ func (e *Emailer) Push(m messenger.Message) error {
} }
} }
switch srv.EmailFormat { switch m.ContentType {
case "html":
em.HTML = m.Body
case "plain": case "plain":
em.Text = []byte(mtext) em.Text = []byte(m.Body)
default: default:
em.HTML = m.Body em.HTML = m.Body
em.Text = []byte(mtext) if len(m.AltBody) > 0 {
em.Text = m.AltBody
}
} }
return srv.pool.Send(em) return srv.pool.Send(em)

View File

@ -22,6 +22,7 @@ type Message struct {
Subject string Subject string
ContentType string ContentType string
Body []byte Body []byte
AltBody []byte
Headers textproto.MIMEHeader Headers textproto.MIMEHeader
Attachments []Attachment Attachments []Attachment

View File

@ -0,0 +1,41 @@
package migrations
import (
"fmt"
"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 {
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('app.lang', '"en"'),
('app.message_sliding_window', 'false'),
('app.message_sliding_window_duration', '"1h"'),
('app.message_sliding_window_rate', '10000'),
('app.enable_public_subscription_page', 'true')
ON CONFLICT DO NOTHING;
-- Add alternate (plain text) body field on campaigns.
ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL;
`); err != nil {
return err
}
// Until this version, the default template during installation was broken!
// Check if there's a broken default template and if yes, override it with the
// actual one.
tplBody, err := fs.Get("/static/email-templates/default.tpl")
if err != nil {
return fmt.Errorf("error reading default e-mail template: %v", err)
}
if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`,
tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil {
return err
}
return nil
}

View File

@ -37,6 +37,9 @@ const (
CampaignStatusCancelled = "cancelled" CampaignStatusCancelled = "cancelled"
CampaignTypeRegular = "regular" CampaignTypeRegular = "regular"
CampaignTypeOptin = "optin" CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html"
CampaignContentTypePlain = "plain"
// List. // List.
ListTypePrivate = "private" ListTypePrivate = "private"
@ -128,6 +131,17 @@ type SubscriberAttribs map[string]interface{}
// Subscribers represents a slice of Subscriber. // Subscribers represents a slice of Subscriber.
type Subscribers []Subscriber type Subscribers []Subscriber
// SubscriberExport represents a subscriber record that is exported to raw data.
type SubscriberExport struct {
Base
UUID string `db:"uuid" json:"uuid"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Attribs string `db:"attribs" json:"attribs"`
Status string `db:"status" json:"status"`
}
// List represents a mailing list. // List represents a mailing list.
type List struct { type List struct {
Base Base
@ -159,6 +173,7 @@ type Campaign struct {
Subject string `db:"subject" json:"subject"` Subject string `db:"subject" json:"subject"`
FromEmail string `db:"from_email" json:"from_email"` FromEmail string `db:"from_email" json:"from_email"`
Body string `db:"body" json:"body"` Body string `db:"body" json:"body"`
AltBody null.String `db:"altbody" json:"altbody"`
SendAt null.Time `db:"send_at" json:"send_at"` SendAt null.Time `db:"send_at" json:"send_at"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
ContentType string `db:"content_type" json:"content_type"` ContentType string `db:"content_type" json:"content_type"`
@ -170,6 +185,7 @@ type Campaign struct {
TemplateBody string `db:"template_body" json:"-"` TemplateBody string `db:"template_body" json:"-"`
Tpl *template.Template `json:"-"` Tpl *template.Template `json:"-"`
SubjectTpl *template.Template `json:"-"` SubjectTpl *template.Template `json:"-"`
AltBodyTpl *template.Template `json:"-"`
// Pseudofield for getting the total number of subscribers // Pseudofield for getting the total number of subscribers
// in searches and queries. // in searches and queries.
@ -310,6 +326,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
if err != nil { if err != nil {
return fmt.Errorf("error inserting child template: %v", err) return fmt.Errorf("error inserting child template: %v", err)
} }
c.Tpl = out
// If the subject line has a template string, compile it. // If the subject line has a template string, compile it.
if strings.Contains(c.Subject, "{{") { if strings.Contains(c.Subject, "{{") {
@ -324,7 +341,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
c.SubjectTpl = subjTpl c.SubjectTpl = subjTpl
} }
c.Tpl = out if strings.Contains(c.AltBody.String, "{{") {
b := c.AltBody.String
for _, r := range regTplFuncs {
b = r.regExp.ReplaceAllString(b, r.replace)
}
bTpl, err := template.New(ContentTpl).Funcs(f).Parse(b)
if err != nil {
return fmt.Errorf("error compiling alt plaintext message: %v", err)
}
c.AltBodyTpl = bTpl
}
return nil return nil
} }

View File

@ -238,6 +238,22 @@ SELECT COUNT(*) OVER () AS total, subscribers.* FROM subscribers
%s %s
ORDER BY %s %s OFFSET $2 LIMIT $3; ORDER BY %s %s OFFSET $2 LIMIT $3;
-- name: query-subscribers-for-export
-- raw: true
-- Unprepared statement for issuring arbitrary WHERE conditions for
-- searching subscribers to do bulk CSV export.
-- %s = arbitrary expression
SELECT s.id, s.uuid, s.email, s.name, s.status, s.attribs, s.created_at, s.updated_at FROM subscribers s
LEFT JOIN subscriber_lists sl
ON (
-- Optional list filtering.
(CASE WHEN CARDINALITY($1::INT[]) > 0 THEN true ELSE false END)
AND sl.subscriber_id = s.id
)
WHERE sl.list_id = ALL($1::INT[]) AND id > $2
%s
ORDER BY s.id ASC LIMIT $3;
-- name: query-subscribers-template -- name: query-subscribers-template
-- raw: true -- raw: true
-- This raw query is reused in multiple queries (blocklist, add to list, delete) -- This raw query is reused in multiple queries (blocklist, add to list, delete)
@ -294,6 +310,9 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
-- lists -- lists
-- name: get-lists -- name: get-lists
SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type END) ORDER by name DESC;
-- name: query-lists
WITH ls AS ( WITH ls AS (
SELECT COUNT(*) OVER () AS total, lists.* FROM lists SELECT COUNT(*) OVER () AS total, lists.* FROM lists
WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT $3 WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT $3
@ -338,16 +357,16 @@ WITH campLists AS (
-- Get the list_ids and their optin statuses for the campaigns found in the previous step. -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
SELECT id AS list_id, campaign_id, optin FROM lists SELECT id AS list_id, campaign_id, optin FROM lists
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id) INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
WHERE id=ANY($12::INT[]) WHERE id=ANY($13::INT[])
), ),
tpl AS ( tpl AS (
-- If there's no template_id given, use the defualt template. -- If there's no template_id given, use the defualt template.
SELECT (CASE WHEN $11 = 0 THEN id ELSE $11 END) AS id FROM templates WHERE is_default IS TRUE SELECT (CASE WHEN $12 = 0 THEN id ELSE $12 END) AS id FROM templates WHERE is_default IS TRUE
), ),
counts AS ( counts AS (
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
FROM subscribers FROM subscribers
LEFT JOIN campLists ON (campLists.campaign_id = ANY($12::INT[])) LEFT JOIN campLists ON (campLists.campaign_id = ANY($13::INT[]))
LEFT JOIN subscriber_lists ON ( LEFT JOIN subscriber_lists ON (
subscriber_lists.status != 'unsubscribed' AND subscriber_lists.status != 'unsubscribed' AND
subscribers.id = subscriber_lists.subscriber_id AND subscribers.id = subscriber_lists.subscriber_id AND
@ -357,16 +376,16 @@ counts AS (
-- any status except for 'unsubscribed' (already excluded above) works. -- any status except for 'unsubscribed' (already excluded above) works.
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END) (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
) )
WHERE subscriber_lists.list_id=ANY($12::INT[]) WHERE subscriber_lists.list_id=ANY($13::INT[])
AND subscribers.status='enabled' AND subscribers.status='enabled'
), ),
camp AS ( camp AS (
INSERT INTO campaigns (uuid, type, name, subject, from_email, body, content_type, send_at, tags, messenger, template_id, to_send, max_subscriber_id) INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, tags, messenger, template_id, to_send, max_subscriber_id)
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts) SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
RETURNING id RETURNING id
) )
INSERT INTO campaign_lists (campaign_id, list_id, list_name) INSERT INTO campaign_lists (campaign_id, list_id, list_name)
(SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($12::INT[])) (SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($13::INT[]))
RETURNING (SELECT id FROM camp); RETURNING (SELECT id FROM camp);
-- name: query-campaigns -- name: query-campaigns
@ -376,19 +395,19 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-- there's a COUNT() OVER() that still returns the total result count -- there's a COUNT() OVER() that still returns the total result count
-- for pagination in the frontend, albeit being a field that'll repeat -- for pagination in the frontend, albeit being a field that'll repeat
-- with every resultant row. -- with every resultant row.
SELECT campaigns.id, campaigns.uuid, campaigns.name, campaigns.subject, campaigns.from_email, SELECT c.id, c.uuid, c.name, c.subject, c.from_email,
campaigns.messenger, campaigns.started_at, campaigns.to_send, campaigns.sent, campaigns.type, c.messenger, c.started_at, c.to_send, c.sent, c.type,
campaigns.body, campaigns.send_at, campaigns.status, campaigns.content_type, campaigns.tags, c.body, c.altbody, c.send_at, c.status, c.content_type, c.tags,
campaigns.template_id, campaigns.created_at, campaigns.updated_at, c.template_id, c.created_at, c.updated_at,
COUNT(*) OVER () AS total, COUNT(*) OVER () AS total,
( (
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM ( SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id, SELECT COALESCE(campaign_lists.list_id, 0) AS id,
campaign_lists.list_name AS name campaign_lists.list_name AS name
FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id FROM campaign_lists WHERE campaign_lists.campaign_id = c.id
) l ) l
) AS lists ) AS lists
FROM campaigns FROM campaigns c
WHERE ($1 = 0 OR id = $1) WHERE ($1 = 0 OR id = $1)
AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END) AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END)
AND ($3 = '' OR CONCAT(name, subject) ILIKE $3) AND ($3 = '' OR CONCAT(name, subject) ILIKE $3)
@ -564,25 +583,26 @@ ORDER BY RANDOM() LIMIT 1;
-- name: update-campaign -- name: update-campaign
WITH camp AS ( WITH camp AS (
UPDATE campaigns SET UPDATE campaigns SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END), name=$2,
subject=(CASE WHEN $3 != '' THEN $3 ELSE subject END), subject=$3,
from_email=(CASE WHEN $4 != '' THEN $4 ELSE from_email END), from_email=$4,
body=(CASE WHEN $5 != '' THEN $5 ELSE body END), body=$5,
content_type=(CASE WHEN $6 != '' THEN $6::content_type ELSE content_type END), altbody=(CASE WHEN $6 = '' THEN NULL ELSE $6 END),
send_at=(CASE WHEN $8 THEN $7::TIMESTAMP WITH TIME ZONE WHEN NOT $8 THEN NULL ELSE send_at END), content_type=$7::content_type,
status=(CASE WHEN NOT $8 THEN 'draft' ELSE status END), send_at=$8::TIMESTAMP WITH TIME ZONE,
tags=$9::VARCHAR(100)[], status=(CASE WHEN NOT $9 THEN 'draft' ELSE status END),
messenger=(CASE WHEN $10 != '' THEN $10 ELSE messenger END), tags=$10::VARCHAR(100)[],
template_id=(CASE WHEN $11 != 0 THEN $11 ELSE template_id END), messenger=$11,
template_id=$12,
updated_at=NOW() updated_at=NOW()
WHERE id = $1 RETURNING id WHERE id = $1 RETURNING id
), ),
d AS ( d AS (
-- Reset list relationships -- Reset list relationships
DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($12)) DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($13))
) )
INSERT INTO campaign_lists (campaign_id, list_id, list_name) INSERT INTO campaign_lists (campaign_id, list_id, list_name)
(SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($12::INT[])) (SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($13::INT[]))
ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name; ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
-- name: update-campaign-counts -- name: update-campaign-counts

View File

@ -75,6 +75,7 @@ CREATE TABLE campaigns (
subject TEXT NOT NULL, subject TEXT NOT NULL,
from_email TEXT NOT NULL, from_email TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
altbody TEXT NULL,
content_type content_type NOT NULL DEFAULT 'richtext', content_type content_type NOT NULL DEFAULT 'richtext',
send_at TIMESTAMP WITH TIME ZONE, send_at TIMESTAMP WITH TIME ZONE,
status campaign_status NOT NULL DEFAULT 'draft', status campaign_status NOT NULL DEFAULT 'draft',
@ -173,7 +174,12 @@ INSERT INTO settings (key, value) VALUES
('app.message_rate', '10'), ('app.message_rate', '10'),
('app.batch_size', '1000'), ('app.batch_size', '1000'),
('app.max_send_errors', '1000'), ('app.max_send_errors', '1000'),
('app.message_sliding_window', 'false'),
('app.message_sliding_window_duration', '"1h"'),
('app.message_sliding_window_rate', '10000'),
('app.enable_public_subscription_page', 'true'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('app.lang', '"en"'),
('privacy.individual_tracking', 'false'), ('privacy.individual_tracking', 'false'),
('privacy.unsubscribe_header', 'true'), ('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'), ('privacy.allow_blocklist', 'true'),

View File

@ -1,22 +1,22 @@
{{ define "campaign-status" }} {{ define "campaign-status" }}
{{ template "header" . }} {{ template "header" . }}
<h2>Campaign update</h2> <h2>{{ L.Ts "email.status.campaignUpdateTitle" }}</h2>
<table width="100%"> <table width="100%">
<tr> <tr>
<td width="30%"><strong>Campaign</strong></td> <td width="30%"><strong>{{ L.Ts "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.Ts "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.Ts "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.Ts "email.status.campaignReason" }}</strong></td>
<td>{{ index . "Reason" }}</td> <td>{{ index . "Reason" }}</td>
</tr> </tr>
{{ end }} {{ end }}

View File

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

View File

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

View File

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

View File

@ -1,17 +1,17 @@
{{ define "optin-campaign" }} {{ define "optin-campaign" }}
<p>Hi {{`{{ .Subscriber.FirstName }}`}},</p> <p>{{ L.Ts "email.optin.confirmSubWelcome" "name" .Subscriber.FirstName }}</p>
<p>You have been added to the following mailing lists:</p> <p>{{ L.Ts "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.Ts "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.Ts "email.optin.confirmSub" }}</a>
</p> </p>
{{ end }} {{ end }}

View File

@ -1,20 +1,20 @@
{{ define "subscriber-optin" }} {{ define "subscriber-optin" }}
{{ template "header" . }} {{ template "header" . }}
<h2>Confirm subscription</h2> <h2>{{ L.Ts "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.Ts "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.Ts "email.optin.privateList" }}</li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> </ul>
<p>Confirm your subscription by clicking the below button.</p> <p>{{ L.Ts "email.optin.confirmSubHelp" }}</p>
<p> <p>
<a href="{{ .OptinURL }}" class="button">Confirm subscription</a> <a href="{{ .OptinURL }}" class="button">{{ L.Ts "email.optin.confirmSub" }}</a>
</p> </p>
{{ template "footer" }} {{ template "footer" }}

View File

@ -1,3 +1,7 @@
* {
box-sizing: border-box;
}
/* Flexit grid */ /* Flexit grid */
.container { .container {
position: relative; position: relative;
@ -195,6 +199,7 @@ a:hover {
} }
label { label {
cursor: pointer; cursor: pointer;
color: #666;
} }
h1, h1,
h2, h2,
@ -202,13 +207,23 @@ h3,
h4 { h4 {
font-weight: 400; font-weight: 400;
} }
section { .section {
margin-bottom: 45px; margin-bottom: 45px;
} }
input[type="text"], input[type="email"], select {
padding: 10px 15px;
border: 1px solid #888;
border-radius: 3px;
width: 100%;
}
input:focus {
border-color: #7f2aff;
}
.button { .button {
background: #7f2aff; background: #7f2aff;
padding: 10px 30px; padding: 15px 30px;
border-radius: 3px; border-radius: 3px;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
@ -216,6 +231,7 @@ section {
color: #ffff; color: #ffff;
display: inline-block; display: inline-block;
min-width: 150px; min-width: 150px;
font-size: 1.1em;
} }
.button:hover { .button:hover {
background: #333; background: #333;
@ -255,6 +271,10 @@ section {
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.form .lists {
margin-top: 45px;
}
.footer { .footer {
text-align: center; text-align: center;
color: #aaa; color: #aaa;

View File

@ -1,9 +1,9 @@
{{ 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">
@ -13,13 +13,15 @@
{{ 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.Ts "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.Ts "public.confirmSub" }}
</button>
</p> </p>
</form> </form>
</section> </section>

View File

@ -0,0 +1,37 @@
{{ define "subscription-form" }}
{{ template "header" .}}
<section>
<h2>{{ L.T "public.subTitle" }}</h2>
<form method="post" action="" class="form">
<div>
<p>
<label>{{ L.T "subscribers.email" }}</label>
<input name="email" required="true" type="email" placeholder="{{ L.T "subscribers.email" }}" >
</p>
<p>
<label>{{ L.T "public.subName" }}</label>
<input name="name" type="text" placeholder="{{ L.T "public.subName" }}" >
</p>
<div class="lists">
<h2>{{ L.T "globals.terms.lists" }}</h2>
{{ range $i, $l := .Data.Lists }}
<div class="row">
<div class="one column">
<input checked="true" id="l-{{ $l.UUID}}" type="checkbox" name="l" value="{{ $l.UUID }}" >
</div>
<div class="eleven columns">
<label for="l-{{ $l.UUID}}">{{ $l.Name }}</label>
</div>
</div>
{{ end }}
</div>
<p>
<button type="submit" class="button">{{ L.T "public.sub" }}</button>
</p>
</div>
</form>
</section>
{{ template "footer" .}}
{{ end }}

View File

@ -1,18 +1,19 @@
{{ define "subscription" }} {{ define "subscription" }}
{{ template "header" .}} {{ template "header" .}}
<section> <section class="section">
<h2>Unsubscribe</h2> <h2>{{ L.T "public.unsubTitle" }}</h2>
<p>Do you wish to unsubscribe from this mailing list?</p> <p>{{ L.T "public.unsubHelp" }}</p>
<form method="post"> <form method="post">
<div> <div>
{{ if .Data.AllowBlocklist }} {{ if .Data.AllowBlocklist }}
<p> <p>
<input id="privacy-blocklist" type="checkbox" name="blocklist" value="true" /> <label for="privacy-blocklist">Also unsubscribe from all future e-mails.</label> <input id="privacy-blocklist" type="checkbox" name="blocklist" value="true" />
<label for="privacy-blocklist">{{ L.T "public.unsubFull" }}</label>
</p> </p>
{{ end }} {{ end }}
<p> <p>
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button> <button type="submit" class="button" id="btn-unsub">{{ L.T "public.unsub" }}</button>
</p> </p>
</div> </div>
</form> </form>
@ -21,16 +22,16 @@
{{ if or .Data.AllowExport .Data.AllowWipe }} {{ if or .Data.AllowExport .Data.AllowWipe }}
<form id="data-form" method="post" action="" onsubmit="return handleData()"> <form id="data-form" method="post" action="" onsubmit="return handleData()">
<section> <section>
<h2>Privacy and data</h2> <h2>{{ L.T "public.privacyTitle" }}</h2>
{{ if .Data.AllowExport }} {{ if .Data.AllowExport }}
<div class="row"> <div class="row">
<div class="one columns"> <div class="one columns">
<input id="privacy-export" type="radio" name="data-action" value="export" required /> <input id="privacy-export" type="radio" name="data-action" value="export" required />
</div> </div>
<div class="ten columns"> <div class="ten columns">
<label for="privacy-export"><strong>Export your data</strong></label> <label for="privacy-export"><strong>{{ L.T "public.privacyExport" }}</strong></label>
<br /> <br />
A copy of your data will be e-mailed to you. {{ L.T "public.privacyExportHelp" }}
</div> </div>
</div> </div>
{{ end }} {{ end }}
@ -41,14 +42,14 @@
<input id="privacy-wipe" type="radio" name="data-action" value="wipe" required /> <input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
</div> </div>
<div class="ten columns"> <div class="ten columns">
<label for="privacy-wipe"><strong>Wipe your data</strong></label> <label for="privacy-wipe"><strong>{{ L.T "public.privacyWipe" }}</strong></label>
<br /> <br />
Delete all your subscriptions and related data from our database permanently. {{ L.T "public.privacyWipeHelp" }}
</div> </div>
</div> </div>
{{ end }} {{ end }}
<p> <p>
<input type="submit" value="Continue" class="button button-outline" /> <input type="submit" value="{{ L.T "globals.buttons.continue" }}" class="button button-outline" />
</p> </p>
</section> </section>
</form> </form>
@ -59,7 +60,7 @@
if (a == "export") { if (a == "export") {
f.action = "/subscription/export/{{ .Data.SubUUID }}"; f.action = "/subscription/export/{{ .Data.SubUUID }}";
return true; return true;
} else if (confirm("Are you sure you want to delete all your subscription data permanently?")) { } else if (confirm("{{ L.T "public.privacyConfirmWipe" }}")) {
f.action = "/subscription/wipe/{{ .Data.SubUUID }}"; f.action = "/subscription/wipe/{{ .Data.SubUUID }}";
return true; return true;
} }