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:
# stuff executables with static assets.
post: make pack-bin bin={{ .Path }}
post: make pack-bin BIN={{ .Path }}
archives:
- format: tar.gz

View File

@ -8,7 +8,8 @@ STATIC := config.toml.sample \
static/public:/public \
static/email-templates \
frontend/dist/favicon.png:/frontend/favicon.png \
frontend/dist/frontend:/frontend
frontend/dist/frontend:/frontend \
i18n:/i18n
# Install dependencies for building.
.PHONY: deps
@ -51,7 +52,7 @@ dist: build build-frontend
# in the .goreleaser post-build hook.
.PHONY: 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.
.PHONY: release-dry

View File

@ -20,6 +20,9 @@ type configScript struct {
MediaProvider string `json:"mediaProvider"`
NeedsRestart bool `json:"needsRestart"`
Update *AppUpdate `json:"update"`
Langs []i18nLang `json:"langs"`
EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"`
Lang json.RawMessage `json:"lang"`
}
// handleGetConfigScript returns general configuration as a Javascript
@ -31,9 +34,21 @@ func handleGetConfigScript(c echo.Context) error {
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
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.
var names []string
for name := range app.messengers {
@ -51,13 +66,17 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update
app.Unlock()
var (
b = bytes.Buffer{}
j = json.NewEncoder(&b)
)
// Write the Javascript variable opening;
b := bytes.Buffer{}
b.Write([]byte(`var CONFIG = `))
_ = j.Encode(out)
return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
// Encode the config payload as JSON and write as the variable's value assignment.
j := json.NewEncoder(&b)
if err := j.Encode(out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("admin.errorMarshallingConfig", "error", err.Error()))
}
return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes())
}
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
@ -69,7 +88,7 @@ func handleGetDashboardCharts(c echo.Context) error {
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
}
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 {
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})

View File

@ -14,6 +14,7 @@ import (
"time"
"github.com/gofrs/uuid"
"github.com/jaytaylor/html2text"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"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 {
app.log.Printf("error fetching campaigns: %v", err)
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 {
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 {
out.Results = []models.Campaign{}
@ -131,7 +134,8 @@ func handleGetCampaigns(c echo.Context) error {
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign stats: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if single {
@ -146,7 +150,7 @@ func handleGetCampaigns(c echo.Context) error {
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 {
var (
app = c.Get("app").(*App)
@ -157,18 +161,20 @@ func handlePreviewCampaign(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetCampaignForPreview.Get(camp, id)
if err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
var sub models.Subscriber
@ -180,7 +186,8 @@ func handlePreviewCampaign(c echo.Context) error {
} else {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
app.i18n.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 {
app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error compiling template: %v", err))
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
@ -200,12 +207,23 @@ func handlePreviewCampaign(c echo.Context) error {
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error rendering message: %v", err))
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
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.
// Newly created campaigns are always drafts.
func handleCreateCampaign(c echo.Context) error {
@ -237,7 +255,8 @@ func handleCreateCampaign(c echo.Context) error {
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
// Insert and read ID.
@ -249,6 +268,7 @@ func handleCreateCampaign(c echo.Context) error {
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
pq.StringArray(normalizeTags(o.Tags)),
@ -257,13 +277,13 @@ func handleCreateCampaign(c echo.Context) error {
o.ListIDs,
); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
"There aren't any subscribers in the target lists to create the campaign.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
}
app.log.Printf("error creating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating campaign: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@ -281,27 +301,31 @@ func handleUpdateCampaign(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if isCampaignalMutable(cm.Status) {
return echo.NewHTTPError(http.StatusBadRequest,
"Cannot update a running or a finished campaign.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
}
// Incoming params.
var o campaignReq
// Read the incoming params into the existing campaign fields from the DB.
// 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 {
return err
}
@ -317,6 +341,7 @@ func handleUpdateCampaign(c echo.Context) error {
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
o.SendLater,
@ -327,7 +352,8 @@ func handleUpdateCampaign(c echo.Context) error {
if err != nil {
app.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return handleGetCampaigns(c)
@ -341,18 +367,20 @@ func handleUpdateCampaignStatus(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Incoming params.
@ -365,27 +393,27 @@ func handleUpdateCampaignStatus(c echo.Context) error {
switch o.Status {
case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled {
errMsg = "Only scheduled campaigns can be saved as drafts"
errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft {
errMsg = "Only draft campaigns can be scheduled"
errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
}
if !cm.SendAt.Valid {
errMsg = "Campaign needs a `send_at` date to be scheduled"
errMsg = app.i18n.T("campaigns.needsSendAt")
}
case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
errMsg = "Only paused campaigns and drafts can be started"
errMsg = app.i18n.T("campaigns.onlyPausedDraft")
}
case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning {
errMsg = "Only active campaigns can be paused"
errMsg = app.i18n.T("campaigns.onlyActivePause")
}
case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
errMsg = "Only active campaigns can be cancelled"
errMsg = app.i18n.T("campaigns.onlyActiveCancel")
}
}
@ -396,12 +424,16 @@ func handleUpdateCampaignStatus(c echo.Context) error {
res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
if err != nil {
app.log.Printf("error updating campaign status: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating campaign status: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return handleGetCampaigns(c)
@ -416,24 +448,29 @@ func handleDeleteCampaign(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
app.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting campaign: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
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)
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 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
@ -488,7 +526,7 @@ func handleTestCampaign(c echo.Context) error {
)
if campID < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
}
// Get and validate fields.
@ -503,7 +541,7 @@ func handleTestCampaign(c echo.Context) error {
req = c
}
if len(req.SubscriberEmails) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubsToTest"))
}
// Get the subscribers.
@ -514,28 +552,33 @@ func handleTestCampaign(c echo.Context) error {
if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
app.log.Printf("error fetching subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} else if len(subs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest"))
}
// The campaign.
var camp models.Campaign
if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
app.i18n.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.Subject = req.Subject
camp.FromEmail = req.FromEmail
camp.Body = req.Body
camp.AltBody = req.AltBody
camp.Messenger = req.Messenger
camp.ContentType = req.ContentType
camp.TemplateID = req.TemplateID
@ -544,8 +587,9 @@ func handleTestCampaign(c echo.Context) error {
for _, s := range subs {
sub := s
if err := sendTestMessage(sub, &camp, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error sending test: %v", err))
app.log.Printf("error sending test message: %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 {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return fmt.Errorf("Error compiling template: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(camp, sub)
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error rendering message: %v", err))
return echo.NewHTTPError(http.StatusNotFound,
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
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(),
ContentType: camp.ContentType,
Body: m.Body(),
AltBody: m.AltBody(),
Subscriber: sub,
Campaign: camp,
})
@ -584,15 +630,15 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
c.FromEmail = app.constants.FromEmail
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !subimporter.IsEmail(c.FromEmail) {
return c, errors.New("invalid `from_email`")
return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail"))
}
}
if !strHasLen(c.Name, 1, stdInputMaxLen) {
return c, errors.New("invalid length for `name`")
return c, errors.New(app.i18n.T("campaigns.fieldInvalidName"))
}
if !strHasLen(c.Subject, 1, stdInputMaxLen) {
return c, errors.New("invalid length for `subject`")
return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject"))
}
// if !hasLen(c.Body, 1, bodyMaxLen) {
@ -602,21 +648,21 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
// If there's a "send_at" date, it should be in the future.
if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) {
return c, errors.New("`send_at` date should be in the future")
return c, errors.New(app.i18n.T("campaigns.fieldInvalidSendAt"))
}
}
if len(c.ListIDs) == 0 {
return c, errors.New("no lists selected")
return c, errors.New(app.i18n.T("campaigns.fieldInvalidListIDs"))
}
if !app.manager.HasMessenger(c.Messenger) {
return c, fmt.Errorf("unknown messenger %s", c.Messenger)
return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
}
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
return c, fmt.Errorf("error compiling campaign body: %v", err)
return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
}
return c, nil
@ -633,7 +679,7 @@ func isCampaignalMutable(status string) bool {
// makeOptinCampaignMessage makes a default opt-in campaign message body.
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
if len(o.ListIDs) == 0 {
return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.")
return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.fieldInvalidListIDs"))
}
// Fetch double opt-in lists from the given list IDs.
@ -642,13 +688,13 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
if err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return o, echo.NewHTTPError(http.StatusInternalServerError,
"Error fetching opt-in lists.")
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
// No opt-in lists.
if len(lists) == 0 {
return o, echo.NewHTTPError(http.StatusBadRequest,
"No opt-in lists found to create campaign.")
return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noOptinLists"))
}
// Construct the opt-in URL with list IDs.
@ -666,8 +712,8 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil {
app.log.Printf("error compiling 'optin-campaign' template: %v", err)
return o, echo.NewHTTPError(http.StatusInternalServerError,
"Error compiling opt-in campaign template.")
return o, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
o.Body = b.String()

View File

@ -2,6 +2,8 @@ package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
@ -31,7 +33,10 @@ type pagination struct {
Limit int `json:"limit"`
}
var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
var (
reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9]")
)
// registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) {
@ -40,6 +45,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript)
g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -66,6 +72,8 @@ func registerHTTPHandlers(e *echo.Echo) {
g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
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/logs", handleGetImportSubscriberStats)
@ -83,6 +91,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/api/campaigns/:id", handleGetCampaigns)
g.GET("/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", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
@ -117,6 +126,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/settings/logs", handleIndexPage)
// Public subscriber facing views.
e.GET("/subscription/form", handleSubscriptionFormPage)
e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
@ -154,6 +164,23 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// handleLoadLanguage returns the JSON language pack given the language code.
func handleLoadLanguage(c echo.Context) error {
app := c.Get("app").(*App)
lang := c.Param("lang")
if len(lang) > 6 || reLangCode.MatchString(lang) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
}
b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
return c.JSON(http.StatusOK, okResp{json.RawMessage(b)})
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)
@ -174,11 +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.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
app := c.Get("app").(*App)
for _, p := range params {
if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "",
`One or more UUIDs in the request are invalid.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("globals.messages.invalidUUID")))
}
}
return next(c)
@ -198,14 +227,14 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc
if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "",
`Error processing request. Please retry.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("public.errorProcessingRequest")))
}
if !exists {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Not found", "",
`Subscription not found.`))
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.subNotFound")))
}
return next(c)
}

44
cmd/i18n.go Normal file
View File

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

View File

@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
@ -27,30 +26,28 @@ func handleImportSubscribers(c echo.Context) error {
// Is an import already running?
if app.importer.GetStats().Status == subimporter.StatusImporting {
return echo.NewHTTPError(http.StatusBadRequest,
"An import is already running. Wait for it to finish or stop it before trying again.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning"))
}
// Unmarsal the JSON params.
var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `params` field: %v", err))
app.i18n.Ts("import.invalidParams", "error", err.Error()))
}
if r.Mode != subimporter.ModeSubscribe && r.Mode != subimporter.ModeBlocklist {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `mode`")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidMode"))
}
if len(r.Delim) != 1 {
return echo.NewHTTPError(http.StatusBadRequest,
"`delim` should be a single character")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidDelim"))
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Invalid `file`: %v", err))
app.i18n.Ts("import.invalidFile", "error", err.Error()))
}
src, err := file.Open()
@ -62,20 +59,20 @@ func handleImportSubscribers(c echo.Context) error {
out, err := ioutil.TempFile("", "listmonk")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
app.i18n.Ts("import.errorCopyingFile", "error", err.Error()))
}
defer out.Close()
if _, err = io.Copy(out, src); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error copying uploaded file: %v", err))
app.i18n.Ts("import.errorCopyingFile", "error", err.Error()))
}
// Start the importer session.
impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error starting import session: %v", err))
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("import.errorStarting", "error", err.Error()))
}
go impSess.Start()
@ -91,7 +88,7 @@ func handleImportSubscribers(c echo.Context) error {
dir, files, err := impSess.ExtractZIP(out.Name(), 1)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error processing ZIP file: %v", err))
app.i18n.Ts("import.errorProcessingZIP", "error", err.Error()))
}
go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0]))
}

View File

@ -20,6 +20,7 @@ import (
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/media/providers/filesystem"
@ -44,6 +45,9 @@ type constants struct {
FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
Lang string `koanf:"lang"`
DBBatchSize int `koanf:"batch_size"`
Privacy struct {
IndividualTracking bool `koanf:"individual_tracking"`
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/*.
"frontend/dist/favicon.png:/frontend/favicon.png",
"frontend/dist/frontend:/frontend",
"i18n:/i18n",
}
fs, err = stuffbin.NewLocalFS("/", files...)
@ -230,6 +235,7 @@ func initConstants() *constants {
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.Lang = ko.String("app.lang")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider")
@ -251,6 +257,36 @@ func initConstants() *constants {
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.
func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
campNotifCB := func(subject string, data interface{}) error {
@ -277,7 +313,10 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL,
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
// 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.
funcs := template.FuncMap{
"RootURL": func() string {
@ -415,7 +454,11 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem
},
"LogoURL": func() string {
return cs.LogoURL
}}
},
"L": func() *i18n.I18n {
return i
},
}
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
if err != nil {
@ -439,7 +482,10 @@ func initHTTPServer(app *App) *echo.Echo {
})
// 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 {
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.
tplBody, err := ioutil.ReadFile("static/email-templates/default.tpl")
tplBody, err := fs.Get("/static/email-templates/default.tpl")
if err != nil {
tplBody = []byte(tplTag)
lo.Fatalf("error reading default e-mail template: %v", err)
}
var tplID int
if err := q.CreateTemplate.Get(&tplID,
"Default template",
string(tplBody),
string(tplBody.ReadBytes()),
); err != nil {
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>",
`<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 }}.`,
nil,
"richtext",
nil,
pq.StringArray{"test-campaign"},

View File

@ -50,13 +50,15 @@ func handleGetLists(c echo.Context) error {
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)
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 {
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 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
@ -93,14 +95,14 @@ func handleCreateList(c echo.Context) error {
// Validate.
if !strHasLen(o.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest,
"Invalid length for the name field.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
}
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
}
// Insert and read ID.
@ -114,7 +116,8 @@ func handleCreateList(c echo.Context) error {
pq.StringArray(normalizeTags(o.Tags))); err != nil {
app.log.Printf("error creating list: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@ -131,7 +134,7 @@ func handleUpdateList(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Incoming params.
@ -144,12 +147,14 @@ func handleUpdateList(c echo.Context) error {
o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags)))
if err != nil {
app.log.Printf("error updating list: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "List not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
return handleGetLists(c)
@ -165,7 +170,7 @@ func handleDeleteLists(c echo.Context) error {
)
if id < 1 && len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if id > 0 {
@ -175,7 +180,8 @@ func handleDeleteLists(c echo.Context) error {
if _, err := app.queries.DeleteLists.Exec(ids); err != nil {
app.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting: %v", err))
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
@ -38,6 +39,7 @@ type tplData struct {
LogoURL string
FaviconURL string
Data interface{}
L *i18n.I18n
}
type publicTpl struct {
@ -66,6 +68,11 @@ type msgTpl struct {
Message string
}
type subFormTpl struct {
publicTpl
Lists []models.List
}
type subForm struct {
subimporter.SubReq
SubListUUIDs []string `form:"l"`
@ -82,6 +89,7 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
LogoURL: t.LogoURL,
FaviconURL: t.FaviconURL,
Data: data,
L: c.Get("app").(*App).i18n,
})
}
@ -99,12 +107,14 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl("Not found", "", `The e-mail campaign was not found.`))
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.campaignNotFound")))
}
app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", `Error fetching e-mail campaign.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
// 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 == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl("Not found", "", `The e-mail message was not found.`))
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.errorFetchingEmail")))
}
app.log.Printf("error fetching campaign subscriber: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", `Error fetching e-mail message.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
// Compile the template.
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", `Error compiling e-mail template.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
// Render the message body.
@ -132,7 +145,8 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", `Error rendering e-mail message.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
return c.HTML(http.StatusOK, string(m.Body()))
@ -151,7 +165,7 @@ func handleSubscriptionPage(c echo.Context) error {
out = unsubTpl{}
)
out.SubUUID = subUUID
out.Title = "Unsubscribe from mailing list"
out.Title = app.i18n.T("public.unsubscribeTitle")
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
out.AllowExport = app.constants.Privacy.AllowExport
out.AllowWipe = app.constants.Privacy.AllowWipe
@ -166,13 +180,13 @@ func handleSubscriptionPage(c echo.Context) error {
if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "",
`Error processing request. Please retry.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Unsubscribed", "",
`You have been successfully unsubscribed.`))
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
app.i18n.T("public.unsubbedInfo")))
}
return c.Render(http.StatusOK, "subscription", out)
@ -189,7 +203,7 @@ func handleOptinPage(c echo.Context) error {
out = optinTpl{}
)
out.SubUUID = subUUID
out.Title = "Confirm subscriptions"
out.Title = app.i18n.T("public.confirmOptinSubTitle")
out.SubUUID = subUUID
// Get and validate fields.
@ -202,8 +216,8 @@ func handleOptinPage(c echo.Context) error {
for _, l := range out.ListUUIDs {
if !reUUID.MatchString(l) {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "",
`One or more UUIDs in the request are invalid.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("globals.messages.invalidUUID")))
}
}
}
@ -212,15 +226,17 @@ func handleOptinPage(c echo.Context) error {
if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingLists")))
}
// There are no lists to confirm.
if len(out.Lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("No subscriptions", "",
`There are no subscriptions to confirm.`))
makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
app.i18n.Ts("public.noSubInfo")))
}
// Confirm.
@ -228,17 +244,52 @@ func handleOptinPage(c echo.Context) error {
if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "",
`Error processing request. Please retry.`))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Confirmed", "",
`Your subscriptions have been confirmed.`))
makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "",
app.i18n.Ts("public.subConfirmed")))
}
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
// HTML subscription forms.
func handleSubscriptionForm(c echo.Context) error {
@ -253,9 +304,9 @@ func handleSubscriptionForm(c echo.Context) error {
}
if len(req.SubListUUIDs) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "",
`No lists to subscribe to.`))
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("public.noListsSelected")))
}
// 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.
if err := subimporter.ValidateFields(req.SubReq); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error", "", err.Error()))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
}
// Insert the subscriber into the DB.
req.Status = models.SubscriberStatusEnabled
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,
makeMsgTpl("Error", "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Done", "", `Subscribed successfully.`))
makeMsgTpl(app.i18n.T("public.subTitle"), "",
app.i18n.Ts("public.subConfirmed")))
}
// 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 pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl("Invalid link", "", "The requested link is invalid."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidLink")))
}
app.log.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error opening link", "", "There was an error opening the link. Please try later."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Redirect(http.StatusTemporaryRedirect, url)
@ -352,7 +406,8 @@ func handleSelfExportSubscriberData(c echo.Context) error {
// Is export allowed?
if !app.constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "", "The feature is not available."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
}
// 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 {
app.log.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
// Prepare the attachment e-mail.
var msg bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
app.log.Printf("error compiling notification template '%s': %v",
notifSubscriberData, err)
app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error preparing data", "",
"There was an error preparing your data. Please try later."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
// Send the data as a JSON attachment to the subscriber.
@ -393,12 +447,13 @@ func handleSelfExportSubscriberData(c echo.Context) error {
}); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error e-mailing data", "",
"There was an error e-mailing your data. Please try later."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Data e-mailed", "",
`Your data has been e-mailed to you as an attachment.`))
makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
app.i18n.T("public.dataSent")))
}
// handleWipeSubscriberData allows a subscriber to delete their data. The
@ -413,20 +468,20 @@ func handleWipeSubscriberData(c echo.Context) error {
// Is wiping allowed?
if !app.constants.Privacy.AllowWipe {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl("Invalid request", "",
"The feature is not available."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
}
if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
app.log.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl("Data removed", "",
`Your subscriptions and all associated data has been removed.`))
makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
app.i18n.T("public.dataRemoved")))
}
// drawTransparentImage draws a transparent PNG of given dimensions

View File

@ -35,6 +35,7 @@ type Queries struct {
// Non-prepared arbitrary subscriber queries.
QuerySubscribers string `query:"query-subscribers"`
QuerySubscribersForExport string `query:"query-subscribers-for-export"`
QuerySubscribersTpl string `query:"query-subscribers-template"`
DeleteSubscribersByQuery string `query:"delete-subscribers-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"`
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"`
UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`

View File

@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
@ -20,11 +19,18 @@ type settings struct {
AppFaviconURL string `json:"app.favicon_url"`
AppFromEmail string `json:"app.from_email"`
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"`
AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"`
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"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
@ -144,8 +150,7 @@ func handleUpdateSettings(c echo.Context) error {
}
}
if !has {
return echo.NewHTTPError(http.StatusBadRequest,
"At least one SMTP block should be enabled.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
}
// Validate and sanitize postback Messenger names. Duplicates are disallowed
@ -169,10 +174,10 @@ func handleUpdateSettings(c echo.Context) error {
name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "")
if _, ok := names[name]; ok {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Duplicate messenger name `%s`.", name))
app.i18n.Ts("settings.duplicateMessengerName", "name", name))
}
if len(name) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid messenger name.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.invalidMessengerName"))
}
set.Messengers[i].Name = name
@ -188,13 +193,14 @@ func handleUpdateSettings(c echo.Context) error {
b, err := json.Marshal(set)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error encoding settings: %v", err))
app.i18n.Ts("settings.errorEncoding", "error", err.Error()))
}
// Update the settings in the DB.
if _, err := app.queries.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating settings: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
// If there are any active campaigns, don't do an auto reload and
@ -232,13 +238,14 @@ func getSettings(app *App) (settings, error) {
if err := app.queries.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching settings: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
// Unmarshall the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error parsing settings: %v", err))
app.i18n.Ts("settings.errorEncoding", "error", err.Error()))
}
return out, nil

View File

@ -3,7 +3,9 @@ package main
import (
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@ -66,6 +68,8 @@ var (
}
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
errSubscriberExists = errors.New("subscriber already exists")
)
// handleGetSubscriber handles the retrieval of a single subscriber by ID.
@ -101,7 +105,7 @@ func handleQuerySubscribers(c echo.Context) error {
listIDs := pq.Int64Array{}
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `list_id`.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
} else if listID > 0 {
listIDs = append(listIDs, int64(listID))
}
@ -126,22 +130,24 @@ func handleQuerySubscribers(c echo.Context) error {
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing subscriber query: %v", pqErrMsg(err)))
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
// Run the query. stmt is the raw SQL query.
if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
// Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error fetching subscriber lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
out.Query = query
@ -158,6 +164,98 @@ func handleQuerySubscribers(c echo.Context) error {
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.
func handleCreateSubscriber(c echo.Context) error {
var (
@ -177,6 +275,10 @@ func handleCreateSubscriber(c echo.Context) error {
// Insert the subscriber into the DB.
sub, err := insertSubscriber(req, app)
if err != nil {
if err == errSubscriberExists {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return err
}
@ -196,13 +298,13 @@ func handleUpdateSubscriber(c echo.Context) error {
}
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if req.Email != "" && !subimporter.IsEmail(req.Email) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `email`.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
}
if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid length for `name`.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}
_, err := app.queries.UpdateSubscriber.Exec(req.ID,
@ -214,7 +316,8 @@ func handleUpdateSubscriber(c echo.Context) error {
if err != nil {
app.log.Printf("error updating subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating subscriber: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
// Send a confirmation e-mail (if there are any double opt-in lists).
@ -236,7 +339,7 @@ func handleSubscriberSendOptin(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Fetch the subscriber.
@ -244,15 +347,17 @@ func handleSubscriberSendOptin(c echo.Context) error {
if err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := sendOptinConfirmation(out[0], nil, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
"Error sending opt-in e-mail.")
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.T("subscribers.errorSendingOptin"))
}
return c.JSON(http.StatusOK, okResp{true})
@ -271,7 +376,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else {
@ -279,7 +384,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("One or more invalid IDs given: %v", err))
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
@ -291,7 +396,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error blocklisting: %v", err))
app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
}
return c.JSON(http.StatusOK, okResp{true})
@ -311,7 +416,7 @@ func handleManageSubscriberLists(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
}
@ -319,17 +424,16 @@ func handleManageSubscriberLists(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("One or more invalid IDs given: %v", err))
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
"No IDs given.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
}
if len(IDs) == 0 {
IDs = req.SubscriberIDs
}
if len(req.TargetListIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No lists given.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
}
// Action.
@ -342,13 +446,14 @@ func handleManageSubscriberLists(c echo.Context) error {
case "unsubscribe":
_, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
default:
return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error processing lists: %v", err))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscribers}", "error", err.Error()))
}
return c.JSON(http.StatusOK, okResp{true})
@ -367,7 +472,7 @@ func handleDeleteSubscribers(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else {
@ -375,11 +480,11 @@ func handleDeleteSubscribers(c echo.Context) error {
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("One or more invalid IDs given: %v", err))
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
"No IDs given.")
app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error()))
}
IDs = i
}
@ -387,7 +492,8 @@ func handleDeleteSubscribers(c echo.Context) error {
if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting subscribers: %v", err))
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@ -409,9 +515,10 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
app.queries.DeleteSubscribersByQuery,
req.ListIDs, app.db)
if err != nil {
app.log.Printf("error querying subscribers: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error: %v", err))
app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@ -434,8 +541,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
req.ListIDs, app.db)
if err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error: %v", err))
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@ -453,7 +560,8 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
return err
}
if len(req.TargetListIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No lists given.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.T("subscribers.errorNoListsGiven"))
}
// Action.
@ -466,15 +574,16 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
case "unsubscribe":
stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
default:
return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
stmt, req.ListIDs, app.db, req.TargetListIDs)
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error: %v", err))
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@ -491,7 +600,7 @@ func handleExportSubscriberData(c echo.Context) error {
)
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Get the subscriber's data. A single query that gets the profile,
@ -500,8 +609,9 @@ func handleExportSubscriberData(c echo.Context) error {
_, b, err := exportSubscriberData(id, "", app.constants.Privacy.Exportable, app)
if err != nil {
app.log.Printf("error exporting subscriber data: %s", err)
return echo.NewHTTPError(http.StatusBadRequest,
"Error exporting subscriber data.")
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", err.Error()))
}
c.Response().Header().Set("Cache-Control", "no-cache")
@ -527,12 +637,13 @@ func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, erro
req.ListUUIDs)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return req.Subscriber, echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
return req.Subscriber, errSubscriberExists
}
app.log.Printf("error inserting subscriber: %v", err)
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.
@ -553,21 +664,25 @@ func getSubscriber(id int, app *App) (models.Subscriber, error) {
)
if id < 1 {
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
return models.Subscriber{},
echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error loading subscriber lists: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
"Error loading subscriber lists.")
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return out[0], nil
@ -647,8 +762,8 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
// Send the e-mail.
if err := app.sendNotification([]string{sub.Email},
"Confirm subscription", notifSubscriberOptin, out); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error sending opt-in e-mail: %s", err)
return err
}
return nil

View File

@ -50,16 +50,17 @@ func handleGetTemplates(c echo.Context) error {
err := app.queries.GetTemplates.Select(&out, id, noBody)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}
if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
if single {
} else if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
@ -79,21 +80,23 @@ func handlePreviewTemplate(c echo.Context) error {
if body != "" {
if !regexpTplTag.MatchString(body) {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Template body should contain the %s placeholder exactly once", tplTag))
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
}
} else {
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetTemplates.Select(&tpls, id, false)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}
if len(tpls) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
body = tpls[0].Body
}
@ -101,22 +104,23 @@ func handlePreviewTemplate(c echo.Context) error {
// Compile the template.
camp := models.Campaign{
UUID: dummyUUID,
Name: "Dummy Campaign",
Subject: "Dummy Campaign Subject",
Name: app.i18n.T("templates.dummyName"),
Subject: app.i18n.T("templates.dummySubject"),
FromEmail: "dummy-campaign@listmonk.app",
TemplateBody: body,
Body: dummyTpl,
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err := m.Render(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error rendering message: %v", err))
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
return c.HTML(http.StatusOK, string(m.Body()))
@ -133,7 +137,7 @@ func handleCreateTemplate(c echo.Context) error {
return err
}
if err := validateTemplate(o); err != nil {
if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
@ -143,7 +147,8 @@ func handleCreateTemplate(c echo.Context) error {
o.Name,
o.Body); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error template user: %v", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@ -160,7 +165,7 @@ func handleUpdateTemplate(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var o models.Template
@ -168,7 +173,7 @@ func handleUpdateTemplate(c echo.Context) error {
return err
}
if err := validateTemplate(o); err != nil {
if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
@ -176,11 +181,13 @@ func handleUpdateTemplate(c echo.Context) error {
res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
return handleGetTemplates(c)
@ -194,13 +201,14 @@ func handleTemplateSetDefault(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
_, err := app.queries.SetDefaultTemplate.Exec(id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
return handleGetTemplates(c)
@ -214,9 +222,10 @@ func handleDeleteTemplate(c echo.Context) error {
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} else if id == 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Cannot delete the primordial template.")
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.T("templates.cantDeleteDefault"))
}
var delID int
@ -226,26 +235,28 @@ func handleDeleteTemplate(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error deleting template: %v", err))
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if delID == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
"Cannot delete the last, default, or non-existent template.")
app.i18n.T("templates.cantDeleteDefault"))
}
return c.JSON(http.StatusOK, okResp{true})
}
// validateTemplate validates template fields.
func validateTemplate(o models.Template) error {
func validateTemplate(o models.Template, app *App) error {
if !strHasLen(o.Name, 1, stdInputMaxLen) {
return errors.New("invalid length for `name`")
return errors.New(app.i18n.T("campaigns.fieldInvalidName"))
}
if !regexpTplTag.MatchString(o.Body) {
return fmt.Errorf("template body should contain the %s placeholder exactly once", tplTag)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
}
return nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,21 +9,22 @@ dayjs.extend(relativeTime);
const reEmail = /(.+?)@(.+?)/ig;
export default class utils {
static months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
'Sep', 'Oct', 'Nov', 'Dec'];
static days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export default class Utils {
constructor(i18n) {
this.i18n = i18n;
}
// Parses an ISO timestamp to a simpler form.
static niceDate = (stamp, showTime) => {
niceDate = (stamp, showTime) => {
if (!stamp) {
return '';
}
const d = new Date(stamp);
let out = `${utils.days[d.getDay()]}, ${d.getDate()}`;
out += ` ${utils.months[d.getMonth()]} ${d.getFullYear()}`;
const day = this.i18n.t(`globals.days.${(d.getDay() + 1)}`);
const month = this.i18n.t(`globals.months.${(d.getMonth() + 1)}`);
let out = `${day}, ${d.getDate()}`;
out += ` ${month} ${d.getFullYear()}`;
if (showTime) {
out += ` ${d.getHours()}:${d.getMinutes()}`;
}
@ -31,14 +32,12 @@ export default class utils {
return out;
};
static duration(start, end) {
return dayjs(end).from(dayjs(start), true);
}
duration = (start, end) => dayjs(end).from(dayjs(start), true);
// 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) {
return 0;
}
@ -69,20 +68,23 @@ export default class utils {
}
// UI shortcuts.
static confirm = (msg, onConfirm, onCancel) => {
confirm = (msg, onConfirm, onCancel) => {
Dialog.confirm({
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,
onCancel,
});
};
static prompt = (msg, inputAttrs, onConfirm, onCancel) => {
prompt = (msg, inputAttrs, onConfirm, onCancel) => {
Dialog.prompt({
scroll: 'clip',
message: msg,
confirmText: 'OK',
confirmText: this.i18n.t('globals.buttons.ok'),
cancelText: this.i18n.t('globals.buttons.cancel'),
inputAttrs: {
type: 'string',
maxlength: 200,
@ -94,7 +96,7 @@ export default class utils {
});
};
static toast = (msg, typ, duration) => {
toast = (msg, typ, duration) => {
Toast.open({
message: msg,
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="data.type === 'optin'" :class="data.type">{{ data.type }}</b-tag>
<span v-if="isEditing" class="has-text-grey-light is-size-7">
ID: {{ data.id }} / UUID: {{ data.uuid }}
{{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</span>
</p>
<h4 v-if="isEditing" class="title is-4">{{ data.name }}</h4>
<h4 v-else class="title is-4">New campaign</h4>
<h4 v-else class="title is-4">{{ $t('campaigns.newCampaign') }}</h4>
</div>
<div class="column">
<div class="buttons" v-if="isEditing && canEdit">
<b-button @click="onSubmit" :loading="loading.campaigns"
type="is-primary" icon-left="content-save-outline">Save changes</b-button>
type="is-primary" icon-left="content-save-outline">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline">
Start campaign
{{ $t('campaigns.start') }}
</b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start">
Schedule campaign
{{ $t('campaigns.schedule') }}
</b-button>
</div>
</div>
@ -33,24 +35,25 @@
<b-loading :active="loading.campaigns"></b-loading>
<b-tabs type="is-boxed" :animated="false" v-model="activeTab">
<b-tab-item label="Campaign" label-position="on-border" icon="rocket-launch-outline">
<b-tab-item :label="$tc('globals.terms.campaign')" label-position="on-border"
icon="rocket-launch-outline">
<section class="wrap">
<div class="columns">
<div class="column is-7">
<form @submit.prevent="onSubmit">
<b-field label="Name" label-position="on-border">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
placeholder="Name" required></b-input>
:placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<b-field label="Subject" label-position="on-border">
<b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
placeholder="Subject" required></b-input>
:placeholder="$t('campaigns.subject')" required></b-input>
</b-field>
<b-field label="From address" label-position="on-border">
<b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
placeholder="Your Name <noreply@yoursite.com>" required></b-input>
:placeholder="$t('campaigns.fromAddressPlaceholder')" required></b-input>
</b-field>
<list-selector
@ -58,35 +61,35 @@
:selected="form.lists"
:all="lists.results"
:disabled="!canEdit"
label="Lists"
placeholder="Lists to send to"
:label="$t('globals.terms.lists')"
:placeholder="$t('campaigns.sendToLists')"
></list-selector>
<b-field label="Template" label-position="on-border">
<b-select placeholder="Template" v-model="form.templateId"
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.templateId"
:disabled="!canEdit" required>
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
</b-select>
</b-field>
<b-field label="Messenger" label-position="on-border">
<b-select placeholder="Messenger" v-model="form.messenger"
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger"
:disabled="!canEdit" required>
<option v-for="m in serverConfig.messengers"
:value="m" :key="m">{{ m }}</option>
</b-select>
</b-field>
<b-field label="Tags" label-position="on-border">
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" :disabled="!canEdit"
ellipsis icon="tag-outline" placeholder="Tags"></b-taginput>
ellipsis icon="tag-outline" :placeholder="$t('globals.terms.tags')" />
</b-field>
<hr />
<div class="columns">
<div class="column is-2">
<b-field label="Send later?">
<b-switch v-model="form.sendLater" :disabled="!canEdit"></b-switch>
<div class="column is-4">
<b-field :label="$t('campaigns.sendLater')">
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field>
</div>
<div class="column">
@ -96,7 +99,7 @@
<b-datetimepicker
v-model="form.sendAtDate"
:disabled="!canEdit"
placeholder="Date and time"
:placeholder="$t('campaigns.dateAndTime')"
icon="calendar-clock"
:timepicker="{ hourFormat: '24' }"
:datetime-formatter="formatDateTime"
@ -109,23 +112,24 @@
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary"
:loading="loading.campaigns">Continue</b-button>
:loading="loading.campaigns">{{ $t('campaigns.continue') }}</b-button>
</b-field>
</form>
</div>
<div class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">Send test message</h3>
<b-field message="Hit Enter after typing an address to add multiple recipients.
The addresses must belong to existing subscribers.">
<h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<b-taginput v-model="form.testEmails"
:before-adding="$utils.validateEmail" :disabled="this.isNew"
ellipsis icon="email-outline" placeholder="E-mails"></b-taginput>
ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
</b-field>
<b-field>
<b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew"
type="is-primary" icon-left="email-outline">Send</b-button>
type="is-primary" icon-left="email-outline">
{{ $t('campaigns.send') }}
</b-button>
</b-field>
</div>
</div>
@ -133,8 +137,7 @@
</section>
</b-tab-item><!-- campaign -->
<b-tab-item label="Content" icon="text" :disabled="isNew">
<section class="wrap">
<b-tab-item :label="$t('campaigns.content')" icon="text" :disabled="isNew">
<editor
v-model="form.content"
:id="data.id"
@ -143,7 +146,21 @@
:body="data.body"
: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-tabs>
</section>
@ -153,6 +170,8 @@
import Vue from 'vue';
import { mapState } from 'vuex';
import dayjs from 'dayjs';
import htmlToPlainText from 'textversionjs';
import ListSelector from '../components/ListSelector.vue';
import Editor from '../components/Editor.vue';
@ -183,6 +202,7 @@ export default Vue.extend({
tags: [],
sendAt: null,
content: { contentType: 'richtext', body: '' },
altbody: null,
// Parsed Date() version of send_at from the API.
sendAtDate: null,
@ -198,6 +218,22 @@ export default Vue.extend({
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) {
return this.$api.getCampaign(id).then((data) => {
this.data = data;
@ -229,23 +265,16 @@ export default Vue.extend({
template_id: this.form.templateId,
content_type: this.form.content.contentType,
body: this.form.content.body,
altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null,
subscribers: this.form.testEmails,
};
this.$api.testCampaign(data).then(() => {
this.$utils.toast('Test message sent');
this.$utils.toast(this.$t('campaigns.testSent'));
});
return false;
},
onSubmit() {
if (this.isNew) {
this.createCampaign();
} else {
this.updateCampaign();
}
},
createCampaign() {
const data = {
name: this.form.name,
@ -280,18 +309,19 @@ export default Vue.extend({
template_id: this.form.templateId,
content_type: this.form.content.contentType,
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') {
typMsg = 'started';
typMsg = 'campaigns.started';
}
// This promise is used by startCampaign to first save before starting.
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d;
this.$utils.toast(`'${d.name}' ${typMsg}`);
this.$utils.toast(this.$t(typMsg, { name: d.name }));
resolve();
});
});
@ -373,7 +403,7 @@ export default Vue.extend({
} else {
const intID = parseInt(id, 10);
if (intID <= 0 || Number.isNaN(intID)) {
this.$utils.toast('Invalid campaign');
this.$utils.toast(this.$t('campaigns.invalid'));
return;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<section class="subscribers">
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">Subscribers
<h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">({{ subscribers.total }})</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
@ -10,7 +10,9 @@
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">New</b-button>
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
</header>
@ -20,7 +22,7 @@
<div>
<b-field grouped>
<b-input @input="onSimpleQueryInput" v-model="queryInput"
placeholder="E-mail or name" icon="magnify" ref="query"
:placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="isSearchAdvanced"></b-button>
@ -28,7 +30,9 @@
<p>
<a href="#" @click.prevent="toggleAdvancedSearch">
<b-icon icon="cog-outline" size="is-small" /> Advanced</a>
<b-icon icon="cog-outline" size="is-small" />
{{ $t('subscribers.advancedQuery') }}
</a>
</p>
<div v-if="isSearchAdvanced">
@ -41,17 +45,20 @@
</b-field>
<b-field>
<span class="is-size-6 has-text-grey">
Partial SQL expression to query subscriber attributes.{{ ' ' }}
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation"
target="_blank" rel="noopener noreferrer"> Learn more.
target="_blank" rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}.
</a>
</span>
</b-field>
<div class="buttons">
<b-button native-type="submit" type="is-primary"
icon-left="magnify">Query</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">Reset</b-button>
icon-left="magnify">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">
{{ $t('subscribers.reset') }}
</b-button>
</div>
</div><!-- advanced query -->
</div>
@ -62,11 +69,13 @@
<div>
<p>
<span class="is-size-5 has-text-weight-semibold">
{{ numSelectedSubscribers }} subscriber(s) selected
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
</span>
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash; <a href="" @click.prevent="selectAllSubscribers">
Select all {{ subscribers.total }}</a>
&mdash;
<a href="" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
</a>
</span>
</p>
@ -95,15 +104,22 @@
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:current-page="queryParams.page" :per-page="subscribers.perPage" :total="subscribers.total"
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">
<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}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">{{ props.row.status }}</b-tag>
<b-tag :class="props.row.status">
{{ $t('subscribers.status.'+ props.row.status) }}
</b-tag>
</a>
</b-table-column>
<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}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.email }}
@ -112,46 +128,47 @@
<router-link :to="`/subscribers/lists/${props.row.id}`">
<b-tag :class="l.subscriptionStatus" v-for="l in props.row.lists"
size="is-small" :key="l.id">
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>
{{ l.name }}
<sup>{{ $t('subscribers.status.'+ l.subscriptionStatus) }}</sup>
</b-tag>
</router-link>
</b-taglist>
</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}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.name }}
</a>
</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) }}
</b-table-column>
<b-table-column field="created_at" label="Created" sortable>
<b-table-column field="created_at" :label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updated_at" label="Updated" sortable>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column class="actions" align="right">
<div>
<a :href="`/api/subscribers/${props.row.id}/export`">
<b-tooltip label="Download data" type="is-dark">
<b-tooltip :label="$t('subscribers.downloadData')" type="is-dark">
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tooltip label="Edit" type="is-dark">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href='' @click.prevent="deleteSubscriber(props.row)">
<b-tooltip label="Delete" type="is-dark">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
@ -183,6 +200,7 @@ import { mapState } from 'vuex';
import SubscriberForm from './SubscriberForm.vue';
import SubscriberBulkList from './SubscriberBulkList.vue';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import { uris } from '../constants';
export default Vue.extend({
components: {
@ -320,13 +338,13 @@ export default Vue.extend({
deleteSubscriber(sub) {
this.$utils.confirm(
'Are you sure?',
null,
() => {
this.$api.deleteSubscriber(sub.id).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: `'${sub.name}' deleted.`,
message: this.$t('globals.messages.deleted', { name: sub.name }),
type: 'is-success',
queue: false,
});
@ -354,10 +372,16 @@ export default Vue.extend({
};
}
this.$utils.confirm(
`Blocklist ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
this.$utils.confirm(this.$t('subscribers.confirmBlocklist', { num: this.numSelectedSubscribers }), 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() {
@ -371,7 +395,7 @@ export default Vue.extend({
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
message: this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }),
type: 'is-success',
queue: false,
});
@ -387,7 +411,7 @@ export default Vue.extend({
this.querySubscribers();
this.$buefy.toast.open({
message: `${this.numSelectedSubscribers} subscriber(s) deleted`,
message: this.$t('subscribers.subscribersDeleted', { num: this.numSelectedSubscribers }),
type: 'is-success',
queue: false,
});
@ -395,10 +419,7 @@ export default Vue.extend({
};
}
this.$utils.confirm(
`Delete ${this.numSelectedSubscribers} subscriber(s)?`,
fn,
);
this.$utils.confirm(this.$t('subscribers.confirmDelete', { num: this.numSelectedSubscribers }), fn);
},
bulkChangeLists(action, lists) {
@ -422,7 +443,7 @@ export default Vue.extend({
fn(data).then(() => {
this.querySubscribers();
this.$buefy.toast.open({
message: 'List change applied',
message: this.$t('subscribers.listChangeApplied'),
type: 'is-success',
queue: false,
});

View File

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

View File

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

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"
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:
version "1.6.0"
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"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==
vue-i18n@^8.22.2:
version "8.22.2"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-8.22.2.tgz#58299a5a050e67b4f799d96fee7dd8bd269e0907"
integrity sha512-rb569fVJInPUgS/bbCxEQ9DrAoFTntuJvYoK4Fpk2VfNbA09WzdTKk57ppjz3S+ps9hW+p9H+2ASgMvojedkow==
vue-loader@^15.9.2:
version "15.9.2"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.9.2.tgz#ae01f5f4c9c6a04bff4483912e72ef91a402c1ae"

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"
"time"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/models"
)
@ -40,25 +41,32 @@ type DataSource interface {
type Manager struct {
cfg Config
src DataSource
i18n *i18n.I18n
messengers map[string]messenger.Messenger
notifCB models.AdminNotifCallback
logger *log.Logger
// Campaigns that are currently running.
camps map[int]*models.Campaign
campsMutex sync.RWMutex
campsMut sync.RWMutex
// 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
// be locked as it may be used externally when previewing campaigns.
links map[string]string
linksMutex sync.RWMutex
linksMut sync.RWMutex
subFetchQueue chan *models.Campaign
campMsgQueue chan CampaignMessage
campMsgErrorQueue chan msgError
campMsgErrorCounts map[int]int
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,
@ -71,6 +79,7 @@ type CampaignMessage struct {
to string
subject string
body []byte
altBody []byte
unsubURL string
}
@ -91,6 +100,9 @@ type Config struct {
Concurrency int
MessageRate int
MaxSendErrors int
SlidingWindow bool
SlidingWindowDuration time.Duration
SlidingWindowRate int
RequeueOnError bool
FromEmail string
IndividualTracking bool
@ -108,7 +120,7 @@ type msgError struct {
}
// 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 {
cfg.BatchSize = 1000
}
@ -122,6 +134,7 @@ func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, l *log.L
return &Manager{
cfg: cfg,
src: src,
i18n: i,
notifCB: notifCB,
logger: l,
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),
campMsgErrorQueue: make(chan msgError, cfg.MaxSendErrors),
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.
func (m *Manager) HasRunningCampaigns() bool {
m.campsMutex.Lock()
defer m.campsMutex.Unlock()
m.campsMut.Lock()
defer m.campsMut.Unlock()
return len(m.camps) > 0
}
@ -252,6 +266,7 @@ func (m *Manager) messageWorker() {
Subject: msg.subject,
ContentType: msg.Campaign.ContentType,
Body: msg.body,
AltBody: msg.altBody,
Subscriber: msg.Subscriber,
Campaign: msg.Campaign,
}
@ -286,6 +301,7 @@ func (m *Manager) messageWorker() {
Subject: msg.Subject,
ContentType: msg.ContentType,
Body: msg.Body,
AltBody: msg.AltBody,
Subscriber: msg.Subscriber,
Campaign: msg.Campaign,
})
@ -334,6 +350,9 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
}
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.
m.campsMutex.Lock()
m.campsMut.Lock()
m.camps[c.ID] = c
m.campsMutex.Unlock()
m.campsMut.Unlock()
return nil
}
// getPendingCampaignIDs returns the IDs of campaigns currently being processed.
func (m *Manager) getPendingCampaignIDs() []int64 {
// 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))
for _, c := range m.camps {
ids = append(ids, int64(c.ID))
}
m.campsMutex.RUnlock()
m.campsMut.RUnlock()
return ids
}
// nextSubscribers processes the next batch of subscribers in a given campaign.
// If returns a bool indicating whether there any subscribers were processed
// in the current batch or not. This can happen when all the subscribers
// have been processed, or if a campaign has been paused or cancelled abruptly.
// It returns a bool indicating whether any subscribers were processed
// in the current batch or not. A false indicates that all subscribers
// have been processed, or that a campaign has been paused or cancelled.
func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, error) {
// Fetch a batch of subscribers.
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
}
// Is there a sliding window limit configured?
hasSliding := m.cfg.SlidingWindow &&
m.cfg.SlidingWindowRate > 0 &&
m.cfg.SlidingWindowDuration.Seconds() > 1
// Push messages.
for _, s := range subs {
// Send the message.
msg := m.NewCampaignMessage(c, s)
if err := msg.Render(); err != nil {
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
// the queue is drained.
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
@ -470,16 +522,16 @@ func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, erro
// isCampaignProcessing checks if the campaign is bing processed.
func (m *Manager) isCampaignProcessing(id int) bool {
m.campsMutex.RLock()
m.campsMut.RLock()
_, ok := m.camps[id]
m.campsMutex.RUnlock()
m.campsMut.RUnlock()
return ok
}
func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Campaign, error) {
m.campsMutex.Lock()
m.campsMut.Lock()
delete(m.camps, c.ID)
m.campsMutex.Unlock()
m.campsMut.Unlock()
// A status has been passed. Change the campaign's status
// 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
// for tracking links.
func (m *Manager) trackLink(url, campUUID, subUUID string) string {
m.linksMutex.RLock()
m.linksMut.RLock()
if uu, ok := m.links[url]; ok {
m.linksMutex.RUnlock()
m.linksMut.RUnlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
}
m.linksMutex.RUnlock()
m.linksMut.RUnlock()
// Register link.
uu, err := m.src.CreateLink(url)
@ -532,9 +584,9 @@ func (m *Manager) trackLink(url, campUUID, subUUID string) string {
return url
}
m.linksMutex.Lock()
m.linksMut.Lock()
m.links[url] = uu
m.linksMutex.Unlock()
m.linksMut.Unlock()
return fmt.Sprintf(m.cfg.LinkTrackURL, uu, campUUID, subUUID)
}
@ -569,10 +621,25 @@ func (m *CampaignMessage) Render() error {
out.Reset()
}
// Compile the main template.
if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
return err
}
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
}
@ -587,3 +654,10 @@ func (m *CampaignMessage) Body() []byte {
copy(out, m.body)
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/textproto"
"github.com/jaytaylor/html2text"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/smtppool"
)
@ -19,7 +18,6 @@ type Server struct {
Username string `json:"username"`
Password string `json:"password"`
AuthProtocol string `json:"auth_protocol"`
EmailFormat string `json:"email_format"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
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{
From: m.From,
To: m.To,
@ -140,14 +132,14 @@ func (e *Emailer) Push(m messenger.Message) error {
}
}
switch srv.EmailFormat {
case "html":
em.HTML = m.Body
switch m.ContentType {
case "plain":
em.Text = []byte(mtext)
em.Text = []byte(m.Body)
default:
em.HTML = m.Body
em.Text = []byte(mtext)
if len(m.AltBody) > 0 {
em.Text = m.AltBody
}
}
return srv.pool.Send(em)

View File

@ -22,6 +22,7 @@ type Message struct {
Subject string
ContentType string
Body []byte
AltBody []byte
Headers textproto.MIMEHeader
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"
CampaignTypeRegular = "regular"
CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html"
CampaignContentTypePlain = "plain"
// List.
ListTypePrivate = "private"
@ -128,6 +131,17 @@ type SubscriberAttribs map[string]interface{}
// Subscribers represents a slice of 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.
type List struct {
Base
@ -159,6 +173,7 @@ type Campaign struct {
Subject string `db:"subject" json:"subject"`
FromEmail string `db:"from_email" json:"from_email"`
Body string `db:"body" json:"body"`
AltBody null.String `db:"altbody" json:"altbody"`
SendAt null.Time `db:"send_at" json:"send_at"`
Status string `db:"status" json:"status"`
ContentType string `db:"content_type" json:"content_type"`
@ -170,6 +185,7 @@ type Campaign struct {
TemplateBody string `db:"template_body" json:"-"`
Tpl *template.Template `json:"-"`
SubjectTpl *template.Template `json:"-"`
AltBodyTpl *template.Template `json:"-"`
// Pseudofield for getting the total number of subscribers
// in searches and queries.
@ -310,6 +326,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
if err != nil {
return fmt.Errorf("error inserting child template: %v", err)
}
c.Tpl = out
// If the subject line has a template string, compile it.
if strings.Contains(c.Subject, "{{") {
@ -324,7 +341,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
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
}

View File

@ -238,6 +238,22 @@ SELECT COUNT(*) OVER () AS total, subscribers.* FROM subscribers
%s
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
-- raw: true
-- 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
-- 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 (
SELECT COUNT(*) OVER () AS total, lists.* FROM lists
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.
SELECT id AS list_id, campaign_id, optin FROM lists
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
WHERE id=ANY($12::INT[])
WHERE id=ANY($13::INT[])
),
tpl AS (
-- 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 (
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
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 (
subscriber_lists.status != 'unsubscribed' AND
subscribers.id = subscriber_lists.subscriber_id AND
@ -357,16 +376,16 @@ counts AS (
-- any status except for 'unsubscribed' (already excluded above) works.
(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'
),
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)
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)
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, $11, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
RETURNING id
)
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);
-- 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
-- for pagination in the frontend, albeit being a field that'll repeat
-- with every resultant row.
SELECT campaigns.id, campaigns.uuid, campaigns.name, campaigns.subject, campaigns.from_email,
campaigns.messenger, campaigns.started_at, campaigns.to_send, campaigns.sent, campaigns.type,
campaigns.body, campaigns.send_at, campaigns.status, campaigns.content_type, campaigns.tags,
campaigns.template_id, campaigns.created_at, campaigns.updated_at,
SELECT c.id, c.uuid, c.name, c.subject, c.from_email,
c.messenger, c.started_at, c.to_send, c.sent, c.type,
c.body, c.altbody, c.send_at, c.status, c.content_type, c.tags,
c.template_id, c.created_at, c.updated_at,
COUNT(*) OVER () AS total,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM (
SELECT COALESCE(campaign_lists.list_id, 0) AS id,
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
) AS lists
FROM campaigns
FROM campaigns c
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 ($3 = '' OR CONCAT(name, subject) ILIKE $3)
@ -564,25 +583,26 @@ ORDER BY RANDOM() LIMIT 1;
-- name: update-campaign
WITH camp AS (
UPDATE campaigns SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
subject=(CASE WHEN $3 != '' THEN $3 ELSE subject END),
from_email=(CASE WHEN $4 != '' THEN $4 ELSE from_email END),
body=(CASE WHEN $5 != '' THEN $5 ELSE body END),
content_type=(CASE WHEN $6 != '' THEN $6::content_type ELSE content_type END),
send_at=(CASE WHEN $8 THEN $7::TIMESTAMP WITH TIME ZONE WHEN NOT $8 THEN NULL ELSE send_at END),
status=(CASE WHEN NOT $8 THEN 'draft' ELSE status END),
tags=$9::VARCHAR(100)[],
messenger=(CASE WHEN $10 != '' THEN $10 ELSE messenger END),
template_id=(CASE WHEN $11 != 0 THEN $11 ELSE template_id END),
name=$2,
subject=$3,
from_email=$4,
body=$5,
altbody=(CASE WHEN $6 = '' THEN NULL ELSE $6 END),
content_type=$7::content_type,
send_at=$8::TIMESTAMP WITH TIME ZONE,
status=(CASE WHEN NOT $9 THEN 'draft' ELSE status END),
tags=$10::VARCHAR(100)[],
messenger=$11,
template_id=$12,
updated_at=NOW()
WHERE id = $1 RETURNING id
),
d AS (
-- 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)
(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;
-- name: update-campaign-counts

View File

@ -75,6 +75,7 @@ CREATE TABLE campaigns (
subject TEXT NOT NULL,
from_email TEXT NOT NULL,
body TEXT NOT NULL,
altbody TEXT NULL,
content_type content_type NOT NULL DEFAULT 'richtext',
send_at TIMESTAMP WITH TIME ZONE,
status campaign_status NOT NULL DEFAULT 'draft',
@ -173,7 +174,12 @@ INSERT INTO settings (key, value) VALUES
('app.message_rate', '10'),
('app.batch_size', '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.lang', '"en"'),
('privacy.individual_tracking', 'false'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),

View File

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

View File

@ -76,7 +76,10 @@
</div>
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
<p>Don't want to receive these e-mails? <a href="{{ UnsubscribeURL }}" style="color: #888;">Unsubscribe</a></p>
<p>
{{ 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>
</div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
{{ define "optin" }}
{{ template "header" .}}
<section>
<h2>Confirm</h2>
<h2>{{ L.T "public.confirmSubTitle" }}</h2>
<p>
You have been added to the following mailing lists:
{{ L.T "public.confirmSubInfo" }}
</p>
<form method="post">
@ -13,13 +13,15 @@
{{ if eq $l.Type "public" }}
<li>{{ $l.Name }}</li>
{{ else }}
<li>Private list</li>
<li>{{ L.Ts "public.subPrivateList" }}</li>
{{ end }}
{{ end }}
</ul>
<p>
<input type="hidden" name="confirm" value="true" />
<button type="submit" class="button" id="btn-unsub">Confirm subscription(s)</button>
<button type="submit" class="button" id="btn-unsub">
{{ L.Ts "public.confirmSub" }}
</button>
</p>
</form>
</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" }}
{{ template "header" .}}
<section>
<h2>Unsubscribe</h2>
<p>Do you wish to unsubscribe from this mailing list?</p>
<section class="section">
<h2>{{ L.T "public.unsubTitle" }}</h2>
<p>{{ L.T "public.unsubHelp" }}</p>
<form method="post">
<div>
{{ if .Data.AllowBlocklist }}
<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>
{{ end }}
<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>
</div>
</form>
@ -21,16 +22,16 @@
{{ if or .Data.AllowExport .Data.AllowWipe }}
<form id="data-form" method="post" action="" onsubmit="return handleData()">
<section>
<h2>Privacy and data</h2>
<h2>{{ L.T "public.privacyTitle" }}</h2>
{{ if .Data.AllowExport }}
<div class="row">
<div class="one columns">
<input id="privacy-export" type="radio" name="data-action" value="export" required />
</div>
<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 />
A copy of your data will be e-mailed to you.
{{ L.T "public.privacyExportHelp" }}
</div>
</div>
{{ end }}
@ -41,14 +42,14 @@
<input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
</div>
<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 />
Delete all your subscriptions and related data from our database permanently.
{{ L.T "public.privacyWipeHelp" }}
</div>
</div>
{{ end }}
<p>
<input type="submit" value="Continue" class="button button-outline" />
<input type="submit" value="{{ L.T "globals.buttons.continue" }}" class="button button-outline" />
</p>
</section>
</form>
@ -59,7 +60,7 @@
if (a == "export") {
f.action = "/subscription/export/{{ .Data.SubUUID }}";
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 }}";
return true;
}