Merge branch 'master' into patch-1

This commit is contained in:
FelixDz 2021-04-14 20:19:00 +02:00 committed by GitHub
commit ca19c5998b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 4070 additions and 1379 deletions

38
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: goreleaser
on:
push:
tags:
- "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`)
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16
- name: Login to Docker Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Prepare Dependencies
run: |
make deps
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
version: latest
args: --parallelism 1 --rm-dist --skip-validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -35,7 +35,7 @@ dockers:
-
goos: linux
goarch: amd64
binaries:
ids:
- listmonk
image_templates:
- "listmonk/listmonk:latest"

View File

@ -5,3 +5,4 @@ COPY listmonk .
COPY config.toml.sample config.toml
COPY config-demo.toml .
CMD ["./listmonk"]
EXPOSE 9000

View File

@ -20,7 +20,7 @@ deps:
# Build the backend to ./listmonk.
.PHONY: build
build:
go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
# Run the backend.
.PHONY: run

View File

@ -14,7 +14,11 @@ Visit [listmonk.app](https://listmonk.app)
The latest image is available on DockerHub at `listmonk/listmonk:latest`. Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows:
#### Demo
`docker-compose up -d demo-db demo-app`
```bash
mkdir listmonk-demo
sh -c "$(curl -sSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"
```
The demo does not persist Postgres after the containers are removed. DO NOT use this demo setup in production.

View File

@ -1,8 +1,6 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sort"
@ -13,41 +11,29 @@ import (
"github.com/labstack/echo"
)
type configScript struct {
RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"`
type serverConfig struct {
Messengers []string `json:"messengers"`
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"`
Lang string `json:"lang"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
}
// handleGetConfigScript returns general configuration as a Javascript
// variable that can be included in an HTML page directly.
func handleGetConfigScript(c echo.Context) error {
// handleGetServerConfig returns general server config.
func handleGetServerConfig(c echo.Context) error {
var (
app = c.Get("app").(*App)
out = configScript{
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
MediaProvider: app.constants.MediaProvider,
EnablePublicSubPage: app.constants.EnablePublicSubPage,
}
out = serverConfig{}
)
// Language list.
langList, err := geti18nLangList(app.constants.Lang, app)
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())
out.Lang = app.constants.Lang
// Sort messenger names with `email` always as the first item.
var names []string
@ -66,17 +52,7 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update
app.Unlock()
// Write the Javascript variable opening;
b := bytes.Buffer{}
b.Write([]byte(`var CONFIG = `))
// 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())
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetDashboardCharts returns chart data points to render ont he dashboard.

View File

@ -155,16 +155,14 @@ func handlePreviewCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body")
camp = &models.Campaign{}
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetCampaignForPreview.Get(camp, id)
var camp models.Campaign
err := app.queries.GetCampaignForPreview.Get(&camp, id)
if err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
@ -177,6 +175,12 @@ func handlePreviewCampaign(c echo.Context) error {
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// There's a body in the request to preview instead of the body in the DB.
if c.Request().Method == http.MethodPost {
camp.ContentType = c.FormValue("content_type")
camp.Body = c.FormValue("body")
}
var sub models.Subscriber
// Get a random subscriber from the campaign.
if err := app.queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
@ -191,19 +195,14 @@ func handlePreviewCampaign(c echo.Context) error {
}
}
// Compile the template.
if body != "" {
camp.Body = body
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(camp, sub)
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,

View File

@ -2,8 +2,6 @@ package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
@ -39,13 +37,21 @@ var (
)
// registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) {
func registerHTTPHandlers(e *echo.Echo, app *App) {
// Group of private handlers with BasicAuth.
g := e.Group("", middleware.BasicAuth(basicAuth))
var g *echo.Group
if len(app.constants.AdminUsername) == 0 ||
len(app.constants.AdminPassword) == 0 {
g = e.Group("")
} else {
g = e.Group("", middleware.BasicAuth(basicAuth))
}
g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript)
g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/config", handleGetServerConfig)
g.GET("/api/lang/:lang", handleGetI18nLang)
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -164,23 +170,6 @@ 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)

View File

@ -3,6 +3,11 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
)
type i18nLang struct {
@ -15,8 +20,25 @@ type i18nLangRaw struct {
Name string `json:"_.name"`
}
// geti18nLangList returns the list of available i18n languages.
func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
// handleGetI18nLang returns the JSON language pack given the language code.
func handleGetI18nLang(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.")
}
i, err := getI18nLang(lang, app.fs)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())})
}
// 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
@ -42,3 +64,30 @@ func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
return out, nil
}
func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) {
const def = "en"
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
if err != nil {
return nil, fmt.Errorf("error reading default i18n language file: %s: %v", def, err)
}
// Initialize with the default language.
i, err := i18n.New(b)
if err != nil {
return nil, fmt.Errorf("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 {
return nil, fmt.Errorf("error reading i18n language file: %v", err)
}
if err := i.Load(b); err != nil {
return nil, fmt.Errorf("error loading i18n language file: %v", err)
}
return i, nil
}

View File

@ -262,28 +262,10 @@ func initConstants() *constants {
// 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))
i, err := getI18nLang(lang, fs)
if err != nil {
lo.Fatalf("error reading default i18n language file: %s: %v", def, err)
lo.Fatal(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
}
@ -505,7 +487,7 @@ func initHTTPServer(app *App) *echo.Echo {
}
// Register all HTTP handlers.
registerHTTPHandlers(srv)
registerHTTPHandlers(srv, app)
// Start the server.
go func() {

View File

@ -118,15 +118,14 @@ func handleViewCampaignMessage(c echo.Context) error {
}
// Get the subscriber.
var sub models.Subscriber
if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil {
sub, err := getSubscriber(0, subUUID, "", app)
if err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
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(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
@ -324,14 +323,18 @@ func handleSubscriptionForm(c echo.Context) 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 && err != errSubscriberExists {
_, _, hasOptin, err := insertSubscriber(req.SubReq, app)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.subTitle"), "",
app.i18n.Ts("public.subConfirmed")))
msg := "public.subConfirmed"
if hasOptin {
msg = "public.subOptinPending"
}
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
}
// handleLinkRedirect redirects a link UUID to its original underlying link

View File

@ -42,6 +42,13 @@ type subsWrap struct {
Page int `json:"page"`
}
type subUpdateReq struct {
models.Subscriber
RawAttribs json.RawMessage `json:"attribs"`
Lists pq.Int64Array `json:"lists"`
ListUUIDs pq.StringArray `json:"list_uuids"`
}
// subProfileData represents a subscriber's collated data in JSON
// for export.
type subProfileData struct {
@ -79,7 +86,11 @@ func handleGetSubscriber(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id"))
)
sub, err := getSubscriber(id, app)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
sub, err := getSubscriber(id, "", "", app)
if err != nil {
return err
}
@ -273,14 +284,13 @@ func handleCreateSubscriber(c echo.Context) error {
}
// Insert the subscriber into the DB.
sub, err := insertSubscriber(req, app)
sub, isNew, _, err := insertSubscriber(req, app)
if err != nil {
if err == errSubscriberExists {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return err
}
if !isNew {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return c.JSON(http.StatusOK, okResp{sub})
}
@ -290,7 +300,7 @@ func handleUpdateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
req subimporter.SubReq
req subUpdateReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
@ -307,11 +317,21 @@ func handleUpdateSubscriber(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}
_, err := app.queries.UpdateSubscriber.Exec(req.ID,
// If there's an attribs value, validate it.
if len(req.RawAttribs) > 0 {
var a models.SubscriberAttribs
if err := json.Unmarshal(req.RawAttribs, &a); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscriber}", "error", err.Error()))
}
}
_, err := app.queries.UpdateSubscriber.Exec(id,
strings.ToLower(strings.TrimSpace(req.Email)),
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.RawAttribs,
req.Lists)
if err != nil {
app.log.Printf("error updating subscriber: %v", err)
@ -321,11 +341,11 @@ func handleUpdateSubscriber(c echo.Context) error {
}
// Send a confirmation e-mail (if there are any double opt-in lists).
sub, err := getSubscriber(int(id), app)
sub, err := getSubscriber(int(id), "", "", app)
if err != nil {
return err
}
_ = sendOptinConfirmation(sub, []int64(req.Lists), app)
_, _ = sendOptinConfirmation(sub, []int64(req.Lists), app)
return c.JSON(http.StatusOK, okResp{sub})
}
@ -335,7 +355,6 @@ func handleSubscriberSendOptin(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
out models.Subscribers
)
if id < 1 {
@ -343,19 +362,15 @@ func handleSubscriberSendOptin(c echo.Context) error {
}
// Fetch the subscriber.
err := app.queries.GetSubscriber.Select(&out, id, nil)
out, err := getSubscriber(id, "", "", app)
if err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := sendOptinConfirmation(out[0], nil, app); err != nil {
if _, err := sendOptinConfirmation(out, nil, app); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.T("subscribers.errorSendingOptin"))
}
@ -619,56 +634,53 @@ func handleExportSubscriberData(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json", b)
}
// insertSubscriber inserts a subscriber and returns the ID.
func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, error) {
// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) {
uu, err := uuid.NewV4()
if err != nil {
return req.Subscriber, err
return req.Subscriber, false, false, err
}
req.UUID = uu.String()
err = app.queries.InsertSubscriber.Get(&req.ID,
isNew := true
if err = app.queries.InsertSubscriber.Get(&req.ID,
req.UUID,
req.Email,
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists,
req.ListUUIDs)
if err != nil {
req.ListUUIDs); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return req.Subscriber, errSubscriberExists
}
isNew = false
} else {
// return req.Subscriber, errSubscriberExists
app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError,
return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
}
// Fetch the subscriber's full data.
sub, err := getSubscriber(req.ID, app)
// Fetch the subscriber's full data. If the subscriber already existed and wasn't
// created, the id will be empty. Fetch the details by e-mail then.
sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app)
if err != nil {
return sub, err
return sub, false, false, err
}
// Send a confirmation e-mail (if there are any double opt-in lists).
_ = sendOptinConfirmation(sub, []int64(req.Lists), app)
return sub, nil
num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app)
return sub, isNew, num > 0, nil
}
// getSubscriber gets a single subscriber by ID.
func getSubscriber(id int, app *App) (models.Subscriber, error) {
var (
out models.Subscribers
)
// getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order.
// Only one of these params should have a value.
func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) {
var out models.Subscribers
if id < 1 {
return models.Subscriber{},
echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil {
if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
@ -733,8 +745,9 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
}
// sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber
// if at least one of the given listIDs is set to optin=double
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) error {
// if at least one of the given listIDs is set to optin=double. It returns the number of
// opt-in lists that were found.
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) {
var lists []models.List
// Fetch double opt-in lists from the given list IDs.
@ -742,12 +755,12 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil,
pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return err
return 0, err
}
// None.
if len(lists) == 0 {
return nil
return 0, nil
}
var (
@ -764,9 +777,9 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
if err := app.sendNotification([]string{sub.Email},
app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error sending opt-in e-mail: %s", err)
return err
return 0, err
}
return nil
return len(lists), nil
}
// sanitizeSQLExp does basic sanitisation on arbitrary

View File

@ -177,8 +177,7 @@ func handleUpdateTemplate(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// TODO: PASSWORD HASHING.
res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
res, err := app.queries.UpdateTemplate.Exec(id, o.Name, o.Body)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",

View File

@ -34,6 +34,8 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
curVersion = reSemver.ReplaceAllString(curVersion, "")
time.Sleep(time.Second * 1)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for ; true; <-ticker.C {
resp, err := http.Get(updateCheckURL)
if err != nil {

View File

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

8
frontend/README.md vendored
View File

@ -5,16 +5,20 @@ It's best if the `listmonk/frontend` directory is opened in an IDE as a separate
For developer setup instructions, refer to the main project's README.
## Globals
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`), `$utils` (util functions from `util.js`), `$serverConfig` (loaded form /api/config.js) are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.
In `main.js`, Buefy and vue-i18n are attached globally. In addition:
- `$api` (collection of API calls from `api/index.js`)
- `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`.
Some constants are defined in `constants.js`.
## APIs and states
The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`.
There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses.
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually.
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`.
## Icon pack

8
frontend/cypress.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"baseUrl": "http://localhost:9000",
"env": {
"server_init_command": "pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &",
"username": "listmonk",
"password": "listmonk"
}
}

View File

@ -0,0 +1,28 @@
{
"profile": [
{
"id": 2,
"uuid": "0954ba2e-50e4-4847-86f4-c2b8b72dace8",
"email": "anon@example.com",
"name": "Anon Doe",
"attribs": {
"city": "Bengaluru",
"good": true,
"type": "unknown"
},
"status": "enabled",
"created_at": "2021-02-20T15:52:16.251648+05:30",
"updated_at": "2021-02-20T15:52:16.251648+05:30"
}
],
"subscriptions": [
{
"subscription_status": "unconfirmed",
"name": "Opt-in list",
"type": "public",
"created_at": "2021-02-20T15:52:16.251648+05:30"
}
],
"campaign_views": [],
"link_clicks": []
}

View File

@ -0,0 +1,101 @@
email,name,attributes
user0@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
user1@mail.com,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
user2@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}"
user3@mail.com,First3 Last3,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX32""}"
user4@mail.com,First4 Last4,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
user5@mail.com,First5 Last5,"{""age"": 69, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
user6@mail.com,First6 Last6,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
user7@mail.com,First7 Last7,"{""age"": 56, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}"
user8@mail.com,First8 Last8,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}"
user9@mail.com,First9 Last9,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX66""}"
user10@mail.com,First10 Last10,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}"
user11@mail.com,First11 Last11,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX59""}"
user12@mail.com,First12 Last12,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}"
user13@mail.com,First13 Last13,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX96""}"
user14@mail.com,First14 Last14,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
user15@mail.com,First15 Last15,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}"
user16@mail.com,First16 Last16,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
user17@mail.com,First17 Last17,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX93""}"
user18@mail.com,First18 Last18,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}"
user19@mail.com,First19 Last19,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX21""}"
user20@mail.com,First20 Last20,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX56""}"
user21@mail.com,First21 Last21,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
user22@mail.com,First22 Last22,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}"
user23@mail.com,First23 Last23,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
user24@mail.com,First24 Last24,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
user25@mail.com,First25 Last25,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}"
user26@mail.com,First26 Last26,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
user27@mail.com,First27 Last27,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
user28@mail.com,First28 Last28,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX45""}"
user29@mail.com,First29 Last29,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
user30@mail.com,First30 Last30,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX27""}"
user31@mail.com,First31 Last31,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX37""}"
user32@mail.com,First32 Last32,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX50""}"
user33@mail.com,First33 Last33,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX29""}"
user34@mail.com,First34 Last34,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX95""}"
user35@mail.com,First35 Last35,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
user36@mail.com,First36 Last36,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
user37@mail.com,First37 Last37,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX92""}"
user38@mail.com,First38 Last38,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX48""}"
user39@mail.com,First39 Last39,"{""age"": 23, ""city"": ""Bangalore"", ""clientId"": ""DAXX12""}"
user40@mail.com,First40 Last40,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}"
user41@mail.com,First41 Last41,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX51""}"
user42@mail.com,First42 Last42,"{""age"": 22, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
user43@mail.com,First43 Last43,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX58""}"
user44@mail.com,First44 Last44,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX15""}"
user45@mail.com,First45 Last45,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
user46@mail.com,First46 Last46,"{""age"": 42, ""city"": ""Bangalore"", ""clientId"": ""DAXX99""}"
user47@mail.com,First47 Last47,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX39""}"
user48@mail.com,First48 Last48,"{""age"": 57, ""city"": ""Bangalore"", ""clientId"": ""DAXX13""}"
user49@mail.com,First49 Last49,"{""age"": 28, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}"
user50@mail.com,First50 Last50,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
user51@mail.com,First51 Last51,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
user52@mail.com,First52 Last52,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}"
user53@mail.com,First53 Last53,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX67""}"
user54@mail.com,First54 Last54,"{""age"": 25, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}"
user55@mail.com,First55 Last55,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
user56@mail.com,First56 Last56,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}"
user57@mail.com,First57 Last57,"{""age"": 32, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}"
user58@mail.com,First58 Last58,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX44""}"
user59@mail.com,First59 Last59,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}"
user60@mail.com,First60 Last60,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}"
user61@mail.com,First61 Last61,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
user62@mail.com,First62 Last62,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
user63@mail.com,First63 Last63,"{""age"": 52, ""city"": ""Bangalore"", ""clientId"": ""DAXX83""}"
user64@mail.com,First64 Last64,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX16""}"
user65@mail.com,First65 Last65,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}"
user66@mail.com,First66 Last66,"{""age"": 35, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
user67@mail.com,First67 Last67,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
user68@mail.com,First68 Last68,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}"
user69@mail.com,First69 Last69,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX24""}"
user70@mail.com,First70 Last70,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
user71@mail.com,First71 Last71,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX57""}"
user72@mail.com,First72 Last72,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
user73@mail.com,First73 Last73,"{""age"": 54, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}"
user74@mail.com,First74 Last74,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}"
user75@mail.com,First75 Last75,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
user76@mail.com,First76 Last76,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}"
user77@mail.com,First77 Last77,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
user78@mail.com,First78 Last78,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX18""}"
user79@mail.com,First79 Last79,"{""age"": 40, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}"
user80@mail.com,First80 Last80,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX72""}"
user81@mail.com,First81 Last81,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}"
user82@mail.com,First82 Last82,"{""age"": 33, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}"
user83@mail.com,First83 Last83,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX88""}"
user84@mail.com,First84 Last84,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}"
user85@mail.com,First85 Last85,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}"
user86@mail.com,First86 Last86,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}"
user87@mail.com,First87 Last87,"{""age"": 20, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
user88@mail.com,First88 Last88,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
user89@mail.com,First89 Last89,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}"
user90@mail.com,First90 Last90,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
user91@mail.com,First91 Last91,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}"
user92@mail.com,First92 Last92,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}"
user93@mail.com,First93 Last93,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
user94@mail.com,First94 Last94,"{""age"": 60, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
user95@mail.com,First95 Last95,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}"
user96@mail.com,First96 Last96,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
user97@mail.com,First97 Last97,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}"
user98@mail.com,First98 Last98,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
user99@mail.com,First99 Last99,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
1 email name attributes
2 user0@mail.com First0 Last0 {"age": 29, "city": "Bangalore", "clientId": "DAXX79"}
3 user1@mail.com First1 Last1 {"age": 43, "city": "Bangalore", "clientId": "DAXX71"}
4 user2@mail.com First2 Last2 {"age": 47, "city": "Bangalore", "clientId": "DAXX70"}
5 user3@mail.com First3 Last3 {"age": 67, "city": "Bangalore", "clientId": "DAXX32"}
6 user4@mail.com First4 Last4 {"age": 63, "city": "Bangalore", "clientId": "DAXX30"}
7 user5@mail.com First5 Last5 {"age": 69, "city": "Bangalore", "clientId": "DAXX64"}
8 user6@mail.com First6 Last6 {"age": 68, "city": "Bangalore", "clientId": "DAXX22"}
9 user7@mail.com First7 Last7 {"age": 56, "city": "Bangalore", "clientId": "DAXX54"}
10 user8@mail.com First8 Last8 {"age": 58, "city": "Bangalore", "clientId": "DAXX65"}
11 user9@mail.com First9 Last9 {"age": 51, "city": "Bangalore", "clientId": "DAXX66"}
12 user10@mail.com First10 Last10 {"age": 53, "city": "Bangalore", "clientId": "DAXX31"}
13 user11@mail.com First11 Last11 {"age": 46, "city": "Bangalore", "clientId": "DAXX59"}
14 user12@mail.com First12 Last12 {"age": 41, "city": "Bangalore", "clientId": "DAXX80"}
15 user13@mail.com First13 Last13 {"age": 27, "city": "Bangalore", "clientId": "DAXX96"}
16 user14@mail.com First14 Last14 {"age": 51, "city": "Bangalore", "clientId": "DAXX22"}
17 user15@mail.com First15 Last15 {"age": 31, "city": "Bangalore", "clientId": "DAXX97"}
18 user16@mail.com First16 Last16 {"age": 59, "city": "Bangalore", "clientId": "DAXX41"}
19 user17@mail.com First17 Last17 {"age": 29, "city": "Bangalore", "clientId": "DAXX93"}
20 user18@mail.com First18 Last18 {"age": 39, "city": "Bangalore", "clientId": "DAXX35"}
21 user19@mail.com First19 Last19 {"age": 67, "city": "Bangalore", "clientId": "DAXX21"}
22 user20@mail.com First20 Last20 {"age": 66, "city": "Bangalore", "clientId": "DAXX56"}
23 user21@mail.com First21 Last21 {"age": 39, "city": "Bangalore", "clientId": "DAXX26"}
24 user22@mail.com First22 Last22 {"age": 44, "city": "Bangalore", "clientId": "DAXX98"}
25 user23@mail.com First23 Last23 {"age": 66, "city": "Bangalore", "clientId": "DAXX64"}
26 user24@mail.com First24 Last24 {"age": 48, "city": "Bangalore", "clientId": "DAXX41"}
27 user25@mail.com First25 Last25 {"age": 38, "city": "Bangalore", "clientId": "DAXX80"}
28 user26@mail.com First26 Last26 {"age": 27, "city": "Bangalore", "clientId": "DAXX26"}
29 user27@mail.com First27 Last27 {"age": 59, "city": "Bangalore", "clientId": "DAXX55"}
30 user28@mail.com First28 Last28 {"age": 49, "city": "Bangalore", "clientId": "DAXX45"}
31 user29@mail.com First29 Last29 {"age": 45, "city": "Bangalore", "clientId": "DAXX74"}
32 user30@mail.com First30 Last30 {"age": 47, "city": "Bangalore", "clientId": "DAXX27"}
33 user31@mail.com First31 Last31 {"age": 21, "city": "Bangalore", "clientId": "DAXX37"}
34 user32@mail.com First32 Last32 {"age": 21, "city": "Bangalore", "clientId": "DAXX50"}
35 user33@mail.com First33 Last33 {"age": 70, "city": "Bangalore", "clientId": "DAXX29"}
36 user34@mail.com First34 Last34 {"age": 59, "city": "Bangalore", "clientId": "DAXX95"}
37 user35@mail.com First35 Last35 {"age": 36, "city": "Bangalore", "clientId": "DAXX79"}
38 user36@mail.com First36 Last36 {"age": 47, "city": "Bangalore", "clientId": "DAXX30"}
39 user37@mail.com First37 Last37 {"age": 36, "city": "Bangalore", "clientId": "DAXX92"}
40 user38@mail.com First38 Last38 {"age": 29, "city": "Bangalore", "clientId": "DAXX48"}
41 user39@mail.com First39 Last39 {"age": 23, "city": "Bangalore", "clientId": "DAXX12"}
42 user40@mail.com First40 Last40 {"age": 39, "city": "Bangalore", "clientId": "DAXX40"}
43 user41@mail.com First41 Last41 {"age": 41, "city": "Bangalore", "clientId": "DAXX51"}
44 user42@mail.com First42 Last42 {"age": 22, "city": "Bangalore", "clientId": "DAXX49"}
45 user43@mail.com First43 Last43 {"age": 68, "city": "Bangalore", "clientId": "DAXX58"}
46 user44@mail.com First44 Last44 {"age": 45, "city": "Bangalore", "clientId": "DAXX15"}
47 user45@mail.com First45 Last45 {"age": 44, "city": "Bangalore", "clientId": "DAXX75"}
48 user46@mail.com First46 Last46 {"age": 42, "city": "Bangalore", "clientId": "DAXX99"}
49 user47@mail.com First47 Last47 {"age": 61, "city": "Bangalore", "clientId": "DAXX39"}
50 user48@mail.com First48 Last48 {"age": 57, "city": "Bangalore", "clientId": "DAXX13"}
51 user49@mail.com First49 Last49 {"age": 28, "city": "Bangalore", "clientId": "DAXX97"}
52 user50@mail.com First50 Last50 {"age": 61, "city": "Bangalore", "clientId": "DAXX75"}
53 user51@mail.com First51 Last51 {"age": 27, "city": "Bangalore", "clientId": "DAXX55"}
54 user52@mail.com First52 Last52 {"age": 62, "city": "Bangalore", "clientId": "DAXX35"}
55 user53@mail.com First53 Last53 {"age": 24, "city": "Bangalore", "clientId": "DAXX67"}
56 user54@mail.com First54 Last54 {"age": 25, "city": "Bangalore", "clientId": "DAXX36"}
57 user55@mail.com First55 Last55 {"age": 39, "city": "Bangalore", "clientId": "DAXX74"}
58 user56@mail.com First56 Last56 {"age": 53, "city": "Bangalore", "clientId": "DAXX28"}
59 user57@mail.com First57 Last57 {"age": 32, "city": "Bangalore", "clientId": "DAXX36"}
60 user58@mail.com First58 Last58 {"age": 64, "city": "Bangalore", "clientId": "DAXX44"}
61 user59@mail.com First59 Last59 {"age": 47, "city": "Bangalore", "clientId": "DAXX65"}
62 user60@mail.com First60 Last60 {"age": 62, "city": "Bangalore", "clientId": "DAXX11"}
63 user61@mail.com First61 Last61 {"age": 24, "city": "Bangalore", "clientId": "DAXX55"}
64 user62@mail.com First62 Last62 {"age": 61, "city": "Bangalore", "clientId": "DAXX49"}
65 user63@mail.com First63 Last63 {"age": 52, "city": "Bangalore", "clientId": "DAXX83"}
66 user64@mail.com First64 Last64 {"age": 38, "city": "Bangalore", "clientId": "DAXX16"}
67 user65@mail.com First65 Last65 {"age": 48, "city": "Bangalore", "clientId": "DAXX54"}
68 user66@mail.com First66 Last66 {"age": 35, "city": "Bangalore", "clientId": "DAXX74"}
69 user67@mail.com First67 Last67 {"age": 70, "city": "Bangalore", "clientId": "DAXX22"}
70 user68@mail.com First68 Last68 {"age": 21, "city": "Bangalore", "clientId": "DAXX98"}
71 user69@mail.com First69 Last69 {"age": 46, "city": "Bangalore", "clientId": "DAXX24"}
72 user70@mail.com First70 Last70 {"age": 58, "city": "Bangalore", "clientId": "DAXX75"}
73 user71@mail.com First71 Last71 {"age": 50, "city": "Bangalore", "clientId": "DAXX57"}
74 user72@mail.com First72 Last72 {"age": 63, "city": "Bangalore", "clientId": "DAXX30"}
75 user73@mail.com First73 Last73 {"age": 54, "city": "Bangalore", "clientId": "DAXX77"}
76 user74@mail.com First74 Last74 {"age": 67, "city": "Bangalore", "clientId": "DAXX91"}
77 user75@mail.com First75 Last75 {"age": 61, "city": "Bangalore", "clientId": "DAXX30"}
78 user76@mail.com First76 Last76 {"age": 50, "city": "Bangalore", "clientId": "DAXX28"}
79 user77@mail.com First77 Last77 {"age": 62, "city": "Bangalore", "clientId": "DAXX41"}
80 user78@mail.com First78 Last78 {"age": 66, "city": "Bangalore", "clientId": "DAXX18"}
81 user79@mail.com First79 Last79 {"age": 40, "city": "Bangalore", "clientId": "DAXX89"}
82 user80@mail.com First80 Last80 {"age": 21, "city": "Bangalore", "clientId": "DAXX72"}
83 user81@mail.com First81 Last81 {"age": 43, "city": "Bangalore", "clientId": "DAXX31"}
84 user82@mail.com First82 Last82 {"age": 33, "city": "Bangalore", "clientId": "DAXX89"}
85 user83@mail.com First83 Last83 {"age": 38, "city": "Bangalore", "clientId": "DAXX88"}
86 user84@mail.com First84 Last84 {"age": 24, "city": "Bangalore", "clientId": "DAXX77"}
87 user85@mail.com First85 Last85 {"age": 27, "city": "Bangalore", "clientId": "DAXX40"}
88 user86@mail.com First86 Last86 {"age": 67, "city": "Bangalore", "clientId": "DAXX46"}
89 user87@mail.com First87 Last87 {"age": 20, "city": "Bangalore", "clientId": "DAXX53"}
90 user88@mail.com First88 Last88 {"age": 45, "city": "Bangalore", "clientId": "DAXX79"}
91 user89@mail.com First89 Last89 {"age": 31, "city": "Bangalore", "clientId": "DAXX11"}
92 user90@mail.com First90 Last90 {"age": 51, "city": "Bangalore", "clientId": "DAXX71"}
93 user91@mail.com First91 Last91 {"age": 49, "city": "Bangalore", "clientId": "DAXX20"}
94 user92@mail.com First92 Last92 {"age": 26, "city": "Bangalore", "clientId": "DAXX20"}
95 user93@mail.com First93 Last93 {"age": 67, "city": "Bangalore", "clientId": "DAXX64"}
96 user94@mail.com First94 Last94 {"age": 60, "city": "Bangalore", "clientId": "DAXX53"}
97 user95@mail.com First95 Last95 {"age": 64, "city": "Bangalore", "clientId": "DAXX91"}
98 user96@mail.com First96 Last96 {"age": 27, "city": "Bangalore", "clientId": "DAXX53"}
99 user97@mail.com First97 Last97 {"age": 29, "city": "Bangalore", "clientId": "DAXX46"}
100 user98@mail.com First98 Last98 {"age": 26, "city": "Bangalore", "clientId": "DAXX49"}
101 user99@mail.com First99 Last99 {"age": 49, "city": "Bangalore", "clientId": "DAXX26"}

View File

@ -0,0 +1,211 @@
describe('Subscribers', () => {
it('Opens campaigns page', () => {
cy.resetDB();
cy.loginAndVisit('/campaigns');
});
it('Counts campaigns', () => {
cy.get('tbody td[data-label=Status]').should('have.length', 1);
});
it('Edits campaign', () => {
cy.get('td[data-label=Status] a').click();
// Fill fields.
cy.get('input[name=name]').clear().type('new-name');
cy.get('input[name=subject]').clear().type('new-subject');
cy.get('input[name=from_email]').clear().type('new <from@email>');
// Change the list.
cy.get('.list-selector a.delete').click();
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').eq(1).click();
// Clear and redo tags.
cy.get('input[name=tags]').type('{backspace}new-tag{enter}');
// Enable schedule.
cy.get('[data-cy=btn-send-later] .check').click();
cy.get('.datepicker input').click();
cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString());
cy.get('.datepicker-body a.is-selectable:first').click();
cy.get('body').click(1, 1);
// Switch to content tab.
cy.get('.b-tabs nav a').eq(1).click();
// Switch format to plain text.
cy.get('label[data-cy=check-plain]').click();
cy.get('.modal button.is-primary').click();
// Enter body value.
cy.get('textarea[name=content]').clear().type('new-content');
cy.get('button[data-cy=btn-save]').click();
// Schedule.
cy.get('button[data-cy=btn-schedule]').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
// Verify the changes.
cy.request('/api/campaigns/1').should((response) => {
const { data } = response.body;
expect(data.status).to.equal('scheduled');
expect(data.name).to.equal('new-name');
expect(data.subject).to.equal('new-subject');
expect(data.content_type).to.equal('plain');
expect(data.altbody).to.equal(null);
expect(data.send_at).to.not.equal(null);
expect(data.body).to.equal('new-content');
expect(data.lists.length).to.equal(1);
expect(data.lists[0].id).to.equal(2);
expect(data.tags.length).to.equal(1);
expect(data.tags[0]).to.equal('new-tag');
});
cy.get('tbody td[data-label=Status] .tag.scheduled');
});
it('Clones campaign', () => {
for (let n = 0; n < 3; n++) {
// Clone the campaign.
cy.get('[data-cy=btn-clone]').first().click();
cy.get('.modal input').clear().type(`clone${n}`).click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.clickMenu('all-campaigns');
cy.wait(100);
// Verify the newly created row.
cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`);
}
});
it('Searches campaigns', () => {
cy.get('input[name=query]').clear().type('clone2{enter}');
cy.get('tbody tr').its('length').should('eq', 1);
cy.get('tbody td[data-label="Name"]').first().contains('clone2');
cy.get('input[name=query]').clear().type('{enter}');
});
it('Deletes campaign', () => {
// Delete all visible lists.
cy.get('tbody tr').each(() => {
cy.get('tbody a[data-cy=btn-delete]').first().click();
cy.get('.modal button.is-primary').click();
});
// Confirm deletion.
cy.get('table tr.is-empty');
});
it('Adds new campaigns', () => {
const lists = [[1], [1, 2]];
const cTypes = ['richtext', 'html', 'plain'];
let n = 0;
cTypes.forEach((c) => {
lists.forEach((l) => {
// Click the 'new button'
cy.get('[data-cy=btn-new]').click();
cy.wait(100);
// Fill fields.
cy.get('input[name=name]').clear().type(`name${n}`);
cy.get('input[name=subject]').clear().type(`subject${n}`);
l.forEach(() => {
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').first().click();
});
// Add tags.
for (let i = 0; i < 3; i++) {
cy.get('input[name=tags]').type(`tag${i}{enter}`);
}
// Hit 'Continue'.
cy.get('button[data-cy=btn-continue]').click();
cy.wait(250);
// Insert content.
cy.get('.ql-editor').type(`hello${n} \{\{ .Subscriber.Name \}\}`, { parseSpecialCharSequences: false });
cy.get('.ql-editor').type('{enter}');
cy.get('.ql-editor').type('\{\{ .Subscriber.Attribs.city \}\}', { parseSpecialCharSequences: false });
// Select content type.
cy.get(`label[data-cy=check-${c}]`).click();
// If it's not richtext, there's a "you'll lose formatting" prompt.
if (c !== 'richtext') {
cy.get('.modal button.is-primary').click();
}
// Save.
cy.get('button[data-cy=btn-save]').click();
cy.clickMenu('all-campaigns');
cy.wait(250);
// Verify the newly created campaign in the table.
cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`);
cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`);
cy.get('tbody td[data-label="Lists"]').first().then(($el) => {
cy.wrap($el).find('li').should('have.length', l.length);
});
n++;
});
});
// Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI.
cy.request('/api/campaigns?order=asc&order_by=created_at').should((response) => {
const { data } = response.body;
expect(data.total).to.equal(lists.length * cTypes.length);
let n = 0;
cTypes.forEach((c) => {
lists.forEach((l) => {
expect(data.results[n].content_type).to.equal(c);
expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l);
n++;
});
});
});
});
it('Starts and cancels campaigns', () => {
for (let n = 1; n <= 2; n++) {
cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`);
if (n > 1) {
cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`);
}
}
});
it('Sorts campaigns', () => {
const asc = [5, 6, 7, 8, 9, 10];
const desc = [10, 9, 8, 7, 6, 5];
const cases = ['cy-name', 'cy-timestamp'];
cases.forEach((c) => {
cy.sortTable(`thead th.${c}`, asc);
cy.wait(250);
cy.sortTable(`thead th.${c}`, desc);
cy.wait(250);
});
});
});

View File

@ -0,0 +1,28 @@
describe('Dashboard', () => {
it('Opens dashboard', () => {
cy.loginAndVisit('/');
// List counts.
cy.get('[data-cy=lists]')
.should('contain', '2 Lists')
.and('contain', '1 Public')
.and('contain', '1 Private')
.and('contain', '1 Single opt-in')
.and('contain', '1 Double opt-in');
// Campaign counts.
cy.get('[data-cy=campaigns]')
.should('contain', '1 Campaign')
.and('contain', '1 draft');
// Subscriber counts.
cy.get('[data-cy=subscribers]')
.should('contain', '2 Subscribers')
.and('contain', '0 Blocklisted')
.and('contain', '0 Orphans');
// Message count.
cy.get('[data-cy=messages]')
.should('contain', '0 Messages sent');
});
});

View File

@ -0,0 +1,36 @@
describe('Forms', () => {
it('Opens forms page', () => {
cy.resetDB();
cy.loginAndVisit('/lists/forms');
});
it('Checks form URL', () => {
cy.get('a[data-cy=url]').contains('http://localhost:9000');
});
it('Checks public lists', () => {
cy.get('ul[data-cy=lists] li')
.should('contain', 'Opt-in list')
.its('length')
.should('eq', 1);
cy.get('[data-cy=form] pre').should('not.exist');
});
it('Selects public list', () => {
// Click the list checkbox.
cy.get('ul[data-cy=lists] .checkbox').click();
// Make sure the <pre> form HTML has appeared.
cy.get('[data-cy=form] pre').then(($pre) => {
// Check that the ID of the list in the checkbox appears in the HTML.
cy.get('ul[data-cy=lists] input').then(($inp) => {
cy.wrap($pre).contains($inp.val());
});
});
// Click the list checkbox.
cy.get('ul[data-cy=lists] .checkbox').click();
cy.get('[data-cy=form] pre').should('not.exist');
});
});

View File

@ -0,0 +1,50 @@
describe('Import', () => {
it('Opens import page', () => {
cy.resetDB();
cy.loginAndVisit('/subscribers/import');
});
it('Imports subscribers', () => {
const cases = [
{ mode: 'check-subscribe', status: 'enabled', count: 102 },
{ mode: 'check-blocklist', status: 'blocklisted', count: 102 },
];
cases.forEach((c) => {
cy.get(`[data-cy=${c.mode}] .check`).click();
if (c.status === 'enabled') {
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').first().click();
}
cy.fixture('subs.csv').then((data) => {
cy.get('input[type="file"]').attachFile({
fileContent: data.toString(),
fileName: 'subs.csv',
mimeType: 'text/csv',
});
});
cy.get('button.is-primary').click();
cy.get('section.wrap .has-text-success');
cy.get('button.is-primary').click();
cy.wait(100);
// Verify that 100 (+2 default) subs are imported.
cy.loginAndVisit('/subscribers');
cy.wait(100);
cy.get('[data-cy=count]').then(($el) => {
cy.expect(parseInt($el.text().trim())).to.equal(c.count);
});
cy.get('tbody td[data-label=Status]').each(($el) => {
cy.wrap($el).find(`.tag.${c.status}`);
});
cy.loginAndVisit('/subscribers/import');
cy.wait(100);
});
});
});

View File

@ -0,0 +1,130 @@
describe('Lists', () => {
it('Opens lists page', () => {
cy.resetDB();
cy.loginAndVisit('/lists');
});
it('Counts subscribers in default lists', () => {
cy.get('tbody td[data-label=Subscribers]').contains('1');
});
it('Creates campaign for list', () => {
cy.get('tbody a[data-cy=btn-campaign]').first().click();
cy.location('pathname').should('contain', '/campaigns/new');
cy.get('.list-tags .tag').contains('Default list');
cy.clickMenu('lists', 'all-lists');
});
it('Creates opt-in campaign for list', () => {
cy.get('tbody a[data-cy=btn-send-optin-campaign]').click();
cy.get('.modal button.is-primary').click();
cy.location('pathname').should('contain', '/campaigns/2');
cy.clickMenu('lists', 'all-lists');
});
it('Checks individual subscribers in lists', () => {
const subs = [{ listID: 1, email: 'john@example.com' },
{ listID: 2, email: 'anon@example.com' }];
// Click on each list on the lists page, go the the subscribers page
// for that list, and check the subscriber details.
subs.forEach((s, n) => {
cy.get('tbody td[data-label=Subscribers] a').eq(n).click();
cy.location('pathname').should('contain', `/subscribers/lists/${s.listID}`);
cy.get('tbody tr').its('length').should('eq', 1);
cy.get('tbody td[data-label="E-mail"]').contains(s.email);
cy.clickMenu('lists', 'all-lists');
});
});
it('Edits lists', () => {
// Open the edit popup and edit the default lists.
cy.get('[data-cy=btn-edit]').each(($el, n) => {
cy.wrap($el).click();
cy.get('input[name=name]').clear().type(`list-${n}`);
cy.get('select[name=type]').select('public');
cy.get('select[name=optin]').select('double');
cy.get('input[name=tags]').clear().type(`tag${n}`);
cy.get('button[type=submit]').click();
});
cy.wait(250);
// Confirm the edits.
cy.get('tbody tr').each(($el, n) => {
cy.wrap($el).find('td[data-label=Name]').contains(`list-${n}`);
cy.wrap($el).find('.tags')
.should('contain', 'test')
.and('contain', `tag${n}`);
});
});
it('Deletes lists', () => {
// Delete all visible lists.
cy.get('tbody tr').each(() => {
cy.get('tbody a[data-cy=btn-delete]').first().click();
cy.get('.modal button.is-primary').click();
});
// Confirm deletion.
cy.get('table tr.is-empty');
});
// Add new lists.
it('Adds new lists', () => {
// Open the list form and create lists of multiple type/optin combinations.
const types = ['private', 'public'];
const optin = ['single', 'double'];
let n = 0;
types.forEach((t) => {
optin.forEach((o) => {
const name = `list-${t}-${o}-${n}`;
cy.get('[data-cy=btn-new]').click();
cy.get('input[name=name]').type(name);
cy.get('select[name=type]').select(t);
cy.get('select[name=optin]').select(o);
cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
cy.get('button[type=submit]').click();
// Confirm the addition by inspecting the newly created list row.
const tr = `tbody tr:nth-child(${n + 1})`;
cy.get(`${tr} td[data-label=Name]`).contains(name);
cy.get(`${tr} td[data-label=Type] [data-cy=type-${t}]`);
cy.get(`${tr} td[data-label=Type] [data-cy=optin-${o}]`);
cy.get(`${tr} .tags`)
.should('contain', `tag${n}`)
.and('contain', t)
.and('contain', o);
n++;
});
});
});
// Sort lists by clicking on various headers. At this point, there should be four
// lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
// the expected order of IDs.
it('Sorts lists', () => {
cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
cy.sortTable('thead th.cy-type', [5, 6, 4, 3]);
cy.sortTable('thead th.cy-type', [4, 3, 5, 6]);
cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);
cy.sortTable('thead th.cy-updated_at', [3, 4, 5, 6]);
cy.sortTable('thead th.cy-updated_at', [6, 5, 4, 3]);
});
});

View File

@ -0,0 +1,40 @@
describe('Templates', () => {
it('Opens settings page', () => {
cy.resetDB();
cy.loginAndVisit('/settings');
});
it('Changes some settings', () => {
const rootURL = 'http://127.0.0.1:9000';
const faveURL = 'http://127.0.0.1:9000/public/static/logo.png';
cy.get('input[name="app.root_url"]').clear().type(rootURL);
cy.get('input[name="app.favicon_url"]').type(faveURL);
cy.get('.b-tabs nav a').eq(1).click();
cy.get('.tab-item:visible').find('.field').first()
.find('button')
.first()
.click();
// Enable / disable SMTP and delete one.
cy.get('.b-tabs nav a').eq(4).click();
cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click();
cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click();
cy.get('.modal button.is-primary').click();
cy.get('[data-cy=btn-save]').click();
cy.wait(250);
// Verify the changes.
cy.request('/api/settings').should((response) => {
const { data } = response.body;
expect(data['app.root_url']).to.equal(rootURL);
expect(data['app.favicon_url']).to.equal(faveURL);
expect(data['app.concurrency']).to.equal(9);
expect(data.smtp.length).to.equal(1);
expect(data.smtp[0].enabled).to.equal(true);
});
});
});

View File

@ -0,0 +1,219 @@
describe('Subscribers', () => {
it('Opens subscribers page', () => {
cy.resetDB();
cy.loginAndVisit('/subscribers');
});
it('Counts subscribers', () => {
cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
});
it('Searches subscribers', () => {
const cases = [
{ value: 'john{enter}', count: 1, contains: 'john@example.com' },
{ value: 'anon{enter}', count: 1, contains: 'anon@example.com' },
{ value: '{enter}', count: 2, contains: null },
];
cases.forEach((c) => {
cy.get('[data-cy=search]').clear().type(c.value);
cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
if (c.contains) {
cy.get('tbody td[data-label=E-mail]').contains(c.contains);
}
});
});
it('Advanced searches subscribers', () => {
cy.get('[data-cy=btn-advanced-search]').click();
const cases = [
{ value: 'subscribers.attribs->>\'city\'=\'Bengaluru\'', count: 2 },
{ value: 'subscribers.attribs->>\'city\'=\'Bengaluru\' AND id=1', count: 1 },
{ value: '(subscribers.attribs->>\'good\')::BOOLEAN = true AND name like \'Anon%\'', count: 1 },
];
cases.forEach((c) => {
cy.get('[data-cy=query]').clear().type(c.value);
cy.get('[data-cy=btn-query]').click();
cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
});
cy.get('[data-cy=btn-query-reset]').click();
cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
});
it('Does bulk subscriber list add and remove', () => {
const cases = [
// radio: action to perform, rows: table rows to select and perform on: [expected statuses of those rows after thea action]
{ radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unconfirmed', 'unconfirmed'] } },
{ radio: 'check-list-unsubscribe', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unsubscribed'] } },
{ radio: 'check-list-remove', lists: [0, 1], rows: { 1: [] } },
{ radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unconfirmed', 'unconfirmed'] } },
{ radio: 'check-list-remove', lists: [0], rows: { 0: ['unsubscribed'] } },
{ radio: 'check-list-add', lists: [0], rows: { 0: ['unconfirmed', 'unsubscribed'] } },
];
cases.forEach((c, n) => {
// Select one of the 2 subscriber in the table.
Object.keys(c.rows).forEach((r) => {
cy.get('tbody td.checkbox-cell .checkbox').eq(r).click();
});
// Open the 'manage lists' modal.
cy.get('[data-cy=btn-manage-lists]').click();
// Check both lists in the modal.
c.lists.forEach((l) => {
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').first().click();
});
// Select the radio option in the modal.
cy.get(`[data-cy=${c.radio}] .check`).click();
// Save.
cy.get('.modal button.is-primary').click();
// Check the status of the lists on the subscriber.
Object.keys(c.rows).forEach((r) => {
cy.get('tbody td[data-label=E-mail]').eq(r).find('.tags').then(($el) => {
cy.wrap($el).find('.tag').should('have.length', c.rows[r].length);
c.rows[r].forEach((status, n) => {
// eg: .tag(n).unconfirmed
cy.wrap($el).find(`.tag:nth-child(${n + 1}).${status}`);
});
});
});
});
});
it('Resets subscribers page', () => {
cy.resetDB();
cy.loginAndVisit('/subscribers');
});
it('Edits subscribers', () => {
const status = ['enabled', 'blocklisted'];
const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
// Collect values being edited on each sub to confirm the changes in the next step
// index by their ID shown in the modal.
const rows = {};
// Open the edit popup and edit the default lists.
cy.get('[data-cy=btn-edit]').each(($el, n) => {
const email = `email-${n}@email.com`;
const name = `name-${n}`;
// Open the edit modal.
cy.wrap($el).click();
// Get the ID from the header and proceed to fill the form.
let id = 0;
cy.get('[data-cy=id]').then(($el) => {
id = $el.text();
cy.get('input[name=email]').clear().type(email);
cy.get('input[name=name]').clear().type(name);
cy.get('select[name=status]').select(status[n]);
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').first().click();
cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
cy.get('.modal-card-foot button[type=submit]').click();
rows[id] = { email, name, status: status[n] };
});
});
// Confirm the edits on the table.
cy.get('tbody tr').each(($el) => {
cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((id) => {
cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email);
cy.wrap($el).find('td[data-label=Name]').contains(rows[id].name);
cy.wrap($el).find('td[data-label=Status]').contains(rows[id].status, { matchCase: false });
// Both lists on the enabled sub should be 'unconfirmed' and the blocklisted one, 'unsubscribed.'
cy.wait(250);
cy.wrap($el).find(`.tags .${rows[id].status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
.its('length').should('eq', 2);
cy.wrap($el).find('td[data-label=Lists]').then((l) => {
cy.expect(parseInt(l.text().trim())).to.equal(rows[id].status === 'blocklisted' ? 0 : 2);
});
});
});
});
it('Deletes subscribers', () => {
// Delete all visible lists.
cy.get('tbody tr').each(() => {
cy.get('tbody a[data-cy=btn-delete]').first().click();
cy.get('.modal button.is-primary').click();
});
// Confirm deletion.
cy.get('table tr.is-empty');
});
it('Creates new subscribers', () => {
const statuses = ['enabled', 'blocklisted'];
const lists = [[1], [2], [1, 2]];
const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
// Cycle through each status and each list ID combination and create subscribers.
const n = 0;
for (let n = 0; n < 6; n++) {
const email = `email-${n}@email.com`;
const name = `name-${n}`;
const status = statuses[(n + 1) % statuses.length];
const list = lists[(n + 1) % lists.length];
cy.get('[data-cy=btn-new]').click();
cy.get('input[name=email]').type(email);
cy.get('input[name=name]').type(name);
cy.get('select[name=status]').select(status);
list.forEach((l) => {
cy.get('.list-selector input').click();
cy.get('.list-selector .autocomplete a').first().click();
});
cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
cy.get('.modal-card-foot button[type=submit]').click();
// Confirm the addition by inspecting the newly created list row,
// which is always the first row in the table.
cy.wait(250);
const tr = cy.get('tbody tr:nth-child(1)').then(($el) => {
cy.wrap($el).find('td[data-label=E-mail]').contains(email);
cy.wrap($el).find('td[data-label=Name]').contains(name);
cy.wrap($el).find('td[data-label=Status]').contains(status, { matchCase: false });
cy.wrap($el).find(`.tags .${status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
.its('length').should('eq', list.length);
cy.wrap($el).find('td[data-label=Lists]').then((l) => {
cy.expect(parseInt(l.text().trim())).to.equal(status === 'blocklisted' ? 0 : list.length);
});
});
}
});
it('Sorts subscribers', () => {
const asc = [3, 4, 5, 6, 7, 8];
const desc = [8, 7, 6, 5, 4, 3];
const cases = ['cy-status', 'cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
cases.forEach((c) => {
cy.sortTable(`thead th.${c}`, asc);
cy.wait(100);
cy.sortTable(`thead th.${c}`, desc);
cy.wait(100);
});
});
});

View File

@ -0,0 +1,77 @@
describe('Templates', () => {
it('Opens templates page', () => {
cy.resetDB();
cy.loginAndVisit('/campaigns/templates');
});
it('Counts default templates', () => {
cy.get('tbody td[data-label=Name]').should('have.length', 1);
});
it('Clones template', () => {
// Clone the campaign.
cy.get('[data-cy=btn-clone]').first().click();
cy.get('.modal input').clear().type('cloned').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
// Verify the newly created row.
cy.get('tbody td[data-label="Name"]').eq(1).contains('cloned');
});
it('Edits template', () => {
cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
cy.wait(250);
cy.get('input[name=name]').clear().type('edited');
cy.get('textarea[name=body]').clear().type('<span>test</span> {{ template "content" . }}',
{ parseSpecialCharSequences: false, delay: 0 });
cy.get('footer.modal-card-foot button.is-primary').click();
cy.wait(250);
cy.get('tbody td[data-label="Name"] a').contains('edited');
});
it('Previews templates', () => {
// Edited one sould have a bare body.
cy.get('tbody [data-cy=btn-preview').eq(0).click();
cy.wait(500);
cy.get('.modal-card-body iframe').iframe(() => {
cy.get('span').first().contains('test');
cy.get('p').first().contains('Hi there');
});
cy.get('footer.modal-card-foot button').click();
// Cloned one should have the full template.
cy.get('tbody [data-cy=btn-preview').eq(1).click();
cy.wait(500);
cy.get('.modal-card-body iframe').iframe(() => {
cy.get('.wrap p').first().contains('Hi there');
cy.get('.footer a').first().contains('Unsubscribe');
});
cy.get('footer.modal-card-foot button').click();
});
it('Sets default', () => {
cy.get('tbody td.actions').eq(1).find('[data-cy=btn-set-default]').click();
cy.get('.modal button.is-primary').click();
// The original default shouldn't have default and the new one should have.
cy.get('tbody td.actions').eq(0).then((el) => {
cy.wrap(el).find('[data-cy=btn-delete]').should('exist');
cy.wrap(el).find('[data-cy=btn-set-default]').should('exist');
});
cy.get('tbody td.actions').eq(1).then((el) => {
cy.wrap(el).find('[data-cy=btn-delete]').should('not.exist');
cy.wrap(el).find('[data-cy=btn-set-default]').should('not.exist');
});
});
it('Deletes template', () => {
cy.get('tbody td.actions [data-cy=btn-delete]').first().click();
cy.get('.modal button.is-primary').click();
cy.wait(250);
cy.get('tbody td.actions').should('have.length', 1);
});
});

View File

@ -0,0 +1,21 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -0,0 +1,42 @@
import 'cypress-file-upload';
Cypress.Commands.add('resetDB', () => {
// Although cypress clearly states that a webserver should not be run
// from within it, listmonk is killed, the DB reset, and run again
// in the background. If the DB is reset without restartin listmonk,
// the live Postgres connections in the app throw errors because the
// schema changes midway.
cy.exec(Cypress.env('server_init_command'));
});
// Takes a th class selector of a Buefy table, clicks it sorting the table,
// then compares the values of [td.data-id] attri of all the rows in the
// table against the given IDs, asserting the expected order of sort.
Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => {
cy.get(theadSelector).click();
cy.get('tbody td[data-id]').each(($el, index) => {
expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id')));
});
});
Cypress.Commands.add('loginAndVisit', (url) => {
cy.visit(url, {
auth: {
username: Cypress.env('username'),
password: Cypress.env('password'),
},
});
});
Cypress.Commands.add('clickMenu', (...selectors) => {
selectors.forEach((s) => {
cy.get(`.menu a[data-cy="${s}"]`).click();
});
});
// https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes
Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => {}) => cy
.wrap($iframe)
.should((iframe) => expect(iframe.contents().find('body')).to.exist)
.then((iframe) => cy.wrap(iframe.contents().find('body')))
.within({}, callback));

View File

@ -0,0 +1,16 @@
import './commands';
beforeEach(() => {
cy.server({
ignore: (xhr) => {
// Ignore the webpack dev server calls that interfere in the tests
// when testing with `yarn serve`.
if (xhr.url.indexOf('sockjs-node/') > -1) {
return true;
}
// Return the default cypress whitelist filer.
return xhr.method === 'GET' && /\.(jsx?|html|css)(\?.*)?$/.test(xhr.url);
},
});
});

View File

@ -0,0 +1,6 @@
#!/bin/bash
pkill -9 listmonk
cd ../
./listmonk --install --yes
./listmonk > /dev/null 2>/dev/null &

View File

@ -15,7 +15,7 @@
"codeflask": "^1.4.1",
"core-js": "^3.6.5",
"dayjs": "^1.8.28",
"elliptic": "^6.5.3",
"elliptic": "^6.5.4",
"humps": "^2.0.1",
"lodash": "^4.17.19",
"node-forge": "^0.10.0",
@ -40,6 +40,8 @@
"@vue/cli-service": "~4.4.0",
"@vue/eslint-config-airbnb": "^5.0.2",
"babel-eslint": "^10.1.0",
"cypress": "^6.4.0",
"cypress-file-upload": "^5.0.2",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-vue": "^6.2.2",

View File

@ -7,7 +7,6 @@
<link rel="icon" href="<%= BASE_URL %>frontend/favicon.png" />
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= BASE_URL %>api/config.js" id="server-config"></script>
</head>
<body>
<noscript>

View File

@ -1,6 +1,6 @@
<template>
<div id="app">
<b-navbar :fixed-top="true">
<b-navbar :fixed-top="true" v-if="$root.isLoaded">
<template slot="brand">
<div class="logo">
<router-link :to="{name: 'dashboard'}">
@ -14,7 +14,7 @@
</template>
</b-navbar>
<div class="wrapper">
<div class="wrapper" v-if="$root.isLoaded">
<section class="sidebar">
<b-sidebar
position="static"
@ -32,63 +32,63 @@
</b-menu-item><!-- dashboard -->
<b-menu-item :expanded="activeGroup.lists"
:active="activeGroup.lists"
:active="activeGroup.lists" data-cy="lists"
v-on:update:active="(state) => toggleGroup('lists', state)"
icon="format-list-bulleted-square" :label="$t('globals.terms.lists')">
<b-menu-item :to="{name: 'lists'}" tag="router-link"
:active="activeItem.lists"
:active="activeItem.lists" data-cy="all-lists"
icon="format-list-bulleted-square" :label="$t('menu.allLists')"></b-menu-item>
<b-menu-item :to="{name: 'forms'}" tag="router-link"
:active="activeItem.forms"
:active="activeItem.forms" class="forms"
icon="newspaper-variant-outline" :label="$t('menu.forms')"></b-menu-item>
</b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.subscribers"
:active="activeGroup.subscribers"
:active="activeGroup.subscribers" data-cy="subscribers"
v-on:update:active="(state) => toggleGroup('subscribers', state)"
icon="account-multiple" :label="$t('globals.terms.subscribers')">
<b-menu-item :to="{name: 'subscribers'}" tag="router-link"
:active="activeItem.subscribers"
:active="activeItem.subscribers" data-cy="all-subscribers"
icon="account-multiple" :label="$t('menu.allSubscribers')"></b-menu-item>
<b-menu-item :to="{name: 'import'}" tag="router-link"
:active="activeItem.import"
:active="activeItem.import" data-cy="import"
icon="file-upload-outline" :label="$t('menu.import')"></b-menu-item>
</b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns"
:active="activeGroup.campaigns"
:active="activeGroup.campaigns" data-cy="campaigns"
v-on:update:active="(state) => toggleGroup('campaigns', state)"
icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')">
<b-menu-item :to="{name: 'campaigns'}" tag="router-link"
:active="activeItem.campaigns"
:active="activeItem.campaigns" data-cy="all-campaigns"
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"
:active="activeItem.campaign" data-cy="new-campaign"
icon="plus" :label="$t('menu.newCampaign')"></b-menu-item>
<b-menu-item :to="{name: 'media'}" tag="router-link"
:active="activeItem.media"
:active="activeItem.media" data-cy="media"
icon="image-outline" :label="$t('menu.media')"></b-menu-item>
<b-menu-item :to="{name: 'templates'}" tag="router-link"
:active="activeItem.templates"
:active="activeItem.templates" data-cy="templates"
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"
:active="activeGroup.settings" data-cy="settings"
v-on:update:active="(state) => toggleGroup('settings', state)"
icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{name: 'settings'}" tag="router-link"
:active="activeItem.settings"
:active="activeItem.settings" data-cy="all-settings"
icon="cog-outline" :label="$t('menu.settings')"></b-menu-item>
<b-menu-item :to="{name: 'logs'}" tag="router-link"
:active="activeItem.logs"
:active="activeItem.logs" data-cy="logs"
icon="newspaper-variant-outline" :label="$t('menu.logs')"></b-menu-item>
</b-menu-item><!-- settings -->
</b-menu-list>
@ -100,18 +100,17 @@
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart || serverConfig.update">
<div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app
<div class="global-notices" v-if="serverConfig.needs_restart || serverConfig.update">
<div v-if="serverConfig.needs_restart" class="notification is-danger">
{{ $t('settings.needsRestart') }}
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm(
'Ensure running campaigns are paused. Restart?', reloadApp)">
Restart
@click="$utils.confirm($t('settings.confirmRestart'), reloadApp)">
{{ $t('settings.restart') }}
</b-button>
</div>
<div v-if="serverConfig.update" class="notification is-success">
A new update ({{ serverConfig.update.version }}) is available.
{{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }}
<a :href="serverConfig.update.url" target="_blank">View</a>
</div>
</div>
@ -120,15 +119,7 @@
</div>
</div>
<b-loading v-if="!isLoaded" active>
<div class="has-text-centered">
<h1 class="title">Oops</h1>
<p>
Can't connect to the backend.<br />
Make sure the server is running and refresh this page.
</p>
</div>
</b-loading>
<b-loading v-if="!$root.isLoaded" active />
</div>
</template>
@ -143,7 +134,6 @@ export default Vue.extend({
return {
activeItem: {},
activeGroup: {},
isLoaded: window.CONFIG,
};
},
@ -173,9 +163,10 @@ export default Vue.extend({
// Poll until there's a 200 response, waiting for the app
// to restart and come back up.
const pollId = setInterval(() => {
this.$api.getHealth().then(() => {
clearInterval(pollId);
this.$utils.toast('Reload complete');
document.location.reload();
});
}, 500);
});
},

View File

@ -195,11 +195,17 @@ export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates });
// Settings.
export const getServerConfig = async () => http.get('/api/config',
{ loading: models.serverConfig, store: models.serverConfig, preserveCase: true });
export const getSettings = async () => http.get('/api/settings',
{ loading: models.settings, preserveCase: true });
{ loading: models.settings, store: models.settings, preserveCase: true });
export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings });
export const getLogs = async () => http.get('/api/logs',
{ loading: models.logs });
export const getLang = async (lang) => http.get(`/api/lang/${lang}`,
{ loading: models.lang, preserveCase: true });

View File

@ -11,6 +11,7 @@
<section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading>
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
<input type="hidden" name="content_type" :value="contentType" />
<input type="hidden" name="body" :value="body" />
</form>
@ -42,6 +43,7 @@ export default {
// campaign | template.
type: String,
body: String,
contentType: String,
},
data() {

View File

@ -7,13 +7,20 @@
<div>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="richtext">{{ $t('campaigns.richText') }}</b-radio>
native-value="richtext"
data-cy="check-richtext">{{ $t('campaigns.richText') }}</b-radio>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="html">{{ $t('campaigns.rawHTML') }}</b-radio>
native-value="html"
data-cy="check-html">{{ $t('campaigns.rawHTML') }}</b-radio>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="plain">{{ $t('campaigns.plainText') }}</b-radio>
native-value="markdown"
data-cy="check-markdown">{{ $t('campaigns.markdown') }}</b-radio>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="plain"
data-cy="check-plain">{{ $t('campaigns.plainText') }}</b-radio>
</div>
</b-field>
</div>
@ -40,16 +47,18 @@
<div v-if="form.format === 'html'"
ref="htmlEditor" id="html-editor" class="html-editor"></div>
<!-- plain text editor //-->
<b-input v-if="form.format === 'plain'" v-model="form.body" @input="onEditorChange"
type="textarea" ref="plainEditor" class="plain-editor" />
<!-- plain text / markdown editor //-->
<b-input v-if="form.format === 'plain' || form.format === 'markdown'"
v-model="form.body" @input="onEditorChange"
type="textarea" name="content" ref="plainEditor" class="plain-editor" />
<!-- campaign preview //-->
<campaign-preview v-if="isPreviewing"
@close="onTogglePreview"
type='campaign'
:id='id'
:title='title'
type="campaign"
:id="id"
:title="title"
:contentType="form.format"
:body="form.body"></campaign-preview>
<!-- image picker -->
@ -195,7 +204,7 @@ export default {
},
() => {
// On cancel, undo the radio selection.
this.form.radioFormat = format === 'richtext' ? 'html' : 'richtext';
this.form.radioFormat = this.form.format;
},
);
},
@ -283,6 +292,10 @@ export default {
this.form.format = f;
this.form.radioFormat = f;
if (f === 'plain' || f === 'markdown') {
this.isReady = true;
}
// Trigger the change event so that the body and content type
// are propagated to the parent on first load.
this.onEditorChange();

View File

@ -1,11 +1,11 @@
<template>
<div class="field">
<div class="field list-selector">
<div :class="['list-tags', ...classes]">
<b-taglist>
<b-tag v-for="l in selectedItems"
:key="l.id"
:class="l.subscriptionStatus"
:closable="!disabled && l.subscriptionStatus !== 'unsubscribed'"
:closable="true"
:data-id="l.id"
@close="removeList(l.id)">
{{ l.name }} <sup>{{ l.subscriptionStatus }}</sup>

View File

@ -1,8 +1,6 @@
export const models = Object.freeze({
// This is the config loaded from /api/config.js directly onto the page
// via a <script> tag.
serverConfig: 'serverConfig',
lang: 'lang',
dashboard: 'dashboard',
lists: 'lists',
subscribers: 'subscribers',

View File

@ -1,13 +1,11 @@
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 { models } from './constants';
import Utils from './utils';
// Internationalisation.
@ -18,46 +16,33 @@ Vue.use(Buefy, {});
Vue.config.productionTip = false;
// Globals.
const ut = new Utils(i18n);
Vue.mixin({
computed: {
$utils: () => ut,
$api: () => api,
},
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');
s.remove();
s = document.createElement('script');
s.setAttribute('src', url);
s.setAttribute('id', 'server-config');
s.onload = () => {
store.commit('setModelResponse',
{ 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);
}
Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;
new Vue({
router,
store,
i18n,
render: (h) => h(App),
data: {
isLoaded: false,
},
methods: {
loadConfig() {
api.getServerConfig().then((data) => {
api.getLang(data.lang).then((lang) => {
i18n.locale = data.lang;
i18n.setLocaleMessage(i18n.locale, lang);
this.isLoaded = true;
});
});
},
},
created() {
this.loadConfig();
api.getSettings();
},
}).$mount('#app');

View File

@ -21,7 +21,7 @@ export default class Utils {
}
const d = new Date(stamp);
const day = this.i18n.t(`globals.days.${(d.getDay() + 1)}`);
const day = this.i18n.t(`globals.days.${(d.getDay())}`);
const month = this.i18n.t(`globals.months.${(d.getMonth() + 1)}`);
let out = `${day}, ${d.getDate()}`;
out += ` ${month} ${d.getFullYear()}`;

View File

@ -17,15 +17,15 @@
<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">
type="is-primary" icon-left="content-save-outline" data-cy="btn-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
<b-button v-if="canStart" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="rocket-launch-outline">
type="is-primary" icon-left="rocket-launch-outline" data-cy="btn-start">
{{ $t('campaigns.start') }}
</b-button>
<b-button v-if="canSchedule" @click="startCampaign" :loading="loading.campaigns"
type="is-primary" icon-left="clock-start">
type="is-primary" icon-left="clock-start" data-cy="btn-schedule">
{{ $t('campaigns.schedule') }}
</b-button>
</div>
@ -42,17 +42,20 @@
<div class="column is-7">
<form @submit.prevent="onSubmit">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" :disabled="!canEdit"
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
name="name" :disabled="!canEdit"
:placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<b-field :label="$t('campaigns.subject')" label-position="on-border">
<b-input :maxlength="200" v-model="form.subject" :disabled="!canEdit"
<b-input :maxlength="200" v-model="form.subject"
name="subject" :disabled="!canEdit"
:placeholder="$t('campaigns.subject')" required></b-input>
</b-field>
<b-field :label="$t('campaigns.fromAddress')" label-position="on-border">
<b-input :maxlength="200" v-model="form.fromEmail" :disabled="!canEdit"
<b-input :maxlength="200" v-model="form.fromEmail"
name="from_email" :disabled="!canEdit"
:placeholder="$t('campaigns.fromAddressPlaceholder')" required></b-input>
</b-field>
@ -67,34 +70,34 @@
<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>
name="template" :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="$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"
name="messenger" :disabled="!canEdit" required>
<option v-for="m in messengers"
:value="m" :key="m">{{ m }}</option>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" :disabled="!canEdit"
<b-taginput v-model="form.tags" name="tags" :disabled="!canEdit"
ellipsis icon="tag-outline" :placeholder="$t('globals.terms.tags')" />
</b-field>
<hr />
<div class="columns">
<div class="column is-4">
<b-field :label="$t('campaigns.sendLater')">
<b-field :label="$t('campaigns.sendLater')" data-cy="btn-send-later">
<b-switch v-model="form.sendLater" :disabled="!canEdit" />
</b-field>
</div>
<div class="column">
<br />
<b-field v-if="form.sendLater"
<b-field v-if="form.sendLater" data-cy="send_at"
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker
v-model="form.sendAtDate"
@ -112,7 +115,9 @@
<b-field v-if="isNew">
<b-button native-type="submit" type="is-primary"
:loading="loading.campaigns">{{ $t('campaigns.continue') }}</b-button>
:loading="loading.campaigns" data-cy="btn-continue">
{{ $t('campaigns.continue') }}
</b-button>
</b-field>
</form>
</div>
@ -196,7 +201,7 @@ export default Vue.extend({
form: {
name: '',
subject: '',
fromEmail: window.CONFIG.fromEmail,
fromEmail: '',
templateId: 0,
lists: [],
tags: [],
@ -352,7 +357,7 @@ export default Vue.extend({
},
computed: {
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
...mapState(['settings', 'loading', 'lists', 'templates']),
canEdit() {
return this.isNew
@ -374,6 +379,10 @@ export default Vue.extend({
return this.lists.results.filter((l) => this.selListIDs.indexOf(l.id) > -1);
},
messengers() {
return ['email', ...this.settings.messengers.map((m) => m.name)];
},
},
watch: {
@ -383,6 +392,8 @@ export default Vue.extend({
},
mounted() {
this.form.fromEmail = this.settings['app.from_email'];
const { id } = this.$route.params;
// New campaign.

View File

@ -8,13 +8,15 @@
</div>
<div class="column has-text-right">
<b-button :to="{name: 'campaign', params:{id: 'new'}}" tag="router-link"
type="is-primary" icon-left="plus">{{ $t('globals.buttons.new') }}</b-button>
type="is-primary" icon-left="plus" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
</header>
<form @submit.prevent="getCampaigns">
<b-field grouped>
<b-input v-model="queryParams.query"
<b-input v-model="queryParams.query" name="query"
: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>
@ -29,7 +31,8 @@
hoverable backend-sorting @sort="onSort">
<template slot-scope="props">
<b-table-column class="status" field="status" :label="$t('globals.fields.status')"
width="10%" :id="props.row.id" sortable>
width="10%" :id="props.row.id" sortable
header-class="cy-status" :data-id="props.row.id">
<div>
<p>
<router-link :to="{ name: 'campaign', params: { 'id': props.row.id }}">
@ -46,13 +49,14 @@
<span class="is-size-7 has-text-grey scheduled">
<b-icon icon="alarm" size="is-small" />
{{ $utils.duration(Date(), props.row.sendAt, true) }}
&ndash; {{ $utils.niceDate(props.row.sendAt, true) }}
<br />{{ $utils.niceDate(props.row.sendAt, true) }}
</span>
</b-tooltip>
</p>
</div>
</b-table-column>
<b-table-column field="name" :label="$t('globals.fields.name')" sortable width="25%">
<b-table-column field="name" :label="$t('globals.fields.name')" sortable width="25%"
header-class="cy-name">
<div>
<p>
<b-tag v-if="props.row.type !== 'regular'" class="is-small">
@ -78,7 +82,7 @@
</ul>
</b-table-column>
<b-table-column field="created_at" :label="$t('campaigns.timestamps')"
width="19%" sortable>
width="19%" sortable header-class="cy-timestamp">
<div class="fields timestamps" :set="stats = getCampaignStats(props.row)">
<p>
<label>{{ $t('globals.fields.createdAt') }}</label>
@ -136,33 +140,33 @@
<div>
<a href="" v-if="canStart(props.row)"
@click.prevent="$utils.confirm(null,
() => changeCampaignStatus(props.row, 'running'))">
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start">
<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'))">
() => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause">
<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'))">
() => changeCampaignStatus(props.row, 'running'))" data-cy="btn-resume">
<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($t('campaigns.confirmSchedule'),
() => changeCampaignStatus(props.row, 'scheduled'))">
() => changeCampaignStatus(props.row, 'scheduled'))" data-cy="btn-schedule">
<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)">
<a href="" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview">
<b-tooltip :label="$t('campaigns.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
@ -170,20 +174,22 @@
<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))">
(name) => cloneCampaign(name, props.row))"
data-cy="btn-clone">
<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'))">
() => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel">
<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($tc('campaigns.confirmDelete'),
() => deleteCampaign(props.row))">
() => deleteCampaign(props.row))" data-cy="btn-delete">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>

View File

@ -12,7 +12,7 @@
<div class="tile">
<div class="tile is-parent is-vertical relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<article class="tile is-child notification" data-cy="lists">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.lists.total) }}</p>
@ -43,7 +43,7 @@
</div>
</article><!-- lists -->
<article class="tile is-child notification">
<article class="tile is-child notification" data-cy="campaigns">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.campaigns.total) }}</p>
@ -64,7 +64,7 @@
<div class="tile is-parent relative">
<b-loading v-if="isCountsLoading" active :is-full-page="false" />
<article class="tile is-child notification">
<article class="tile is-child notification" data-cy="subscribers">
<div class="columns is-mobile">
<div class="column is-6">
<p class="title">{{ $utils.niceNumber(counts.subscribers.total) }}</p>
@ -87,7 +87,7 @@
</div><!-- subscriber breakdown -->
</div><!-- subscriber columns -->
<hr />
<div class="columns">
<div class="columns" data-cy="messages">
<div class="column is-12">
<p class="title">{{ $utils.niceNumber(counts.messages) }}</p>
<p class="is-size-6 has-text-grey">

View File

@ -2,38 +2,43 @@
<section class="forms content relative">
<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>{{ $t('forms.publicLists') }}</h4>
<p>{{ $t('forms.selectHelp') }}</p>
<b-loading :active="loading.lists" :is-full-page="false" />
<ul class="no">
<ul class="no" data-cy="lists">
<li v-for="l in publicLists" :key="l.id">
<b-checkbox v-model="checked"
:native-value="l.uuid">{{ l.name }}</b-checkbox>
</li>
</ul>
<template v-if="serverConfig.enablePublicSubscriptionPage">
<template v-if="settings['app.enable_public_subscription_page']">
<hr />
<h4>{{ $t('forms.publicSubPage') }}</h4>
<p>
<a :href="`${serverConfig.rootURL}/subscription/form`"
target="_blank">{{ serverConfig.rootURL }}/subscription/form</a>
<a :href="`${settings['app.root_url']}/subscription/form`"
target="_blank" data-cy="url">{{ settings['app.root_url'] }}/subscription/form</a>
</p>
</template>
</div>
<div class="column">
<div class="column" data-cy="form">
<h4>{{ $t('forms.formHTML') }}</h4>
<p>
{{ $t('forms.formHTMLHelp') }}
</p>
<!-- 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;
<pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ settings['app.root_url'] }}/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;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
@ -73,7 +78,7 @@ export default Vue.extend({
},
computed: {
...mapState(['lists', 'loading', 'serverConfig']),
...mapState(['loading', 'lists', 'settings']),
publicLists() {
if (!this.lists.results) {

View File

@ -11,9 +11,11 @@
<b-field :label="$t('import.mode')">
<div>
<b-radio v-model="form.mode" name="mode"
native-value="subscribe">{{ $t('import.subscribe') }}</b-radio>
native-value="subscribe"
data-cy="check-subscribe">{{ $t('import.subscribe') }}</b-radio>
<b-radio v-model="form.mode" name="mode"
native-value="blocklist">{{ $t('import.blocklist') }}</b-radio>
native-value="blocklist"
data-cy="check-blocklist">{{ $t('import.blocklist') }}</b-radio>
</div>
</b-field>
</div>

View File

@ -12,13 +12,13 @@
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required></b-input>
</b-field>
<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>
<b-select v-model="form.type" name="type" :placeholder="$t('lists.typeHelp')" required>
<option value="private">{{ $t('lists.types.private') }}</option>
<option value="public">{{ $t('lists.types.public') }}</option>
</b-select>
@ -26,14 +26,14 @@
<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>
<b-select v-model="form.optin" name="optin" placeholder="Opt-in type" required>
<option value="single">{{ $t('lists.optins.single') }}</option>
<option value="double">{{ $t('lists.optins.double') }}</option>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" ellipsis
<b-taginput v-model="form.tags" name="tags" ellipsis
icon="tag-outline" :placeholder="$t('globals.terms.tags')"></b-taginput>
</b-field>
</section>

View File

@ -8,7 +8,7 @@
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
<b-button type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
@ -23,9 +23,9 @@
backend-sorting @sort="onSort"
>
<template slot-scope="props">
<b-table-column field="name" :label="$t('globals.fields.name')"
<b-table-column field="name" :label="$t('globals.fields.name')" header-class="cy-name"
sortable width="25%" paginated backend-pagination pagination-position="both"
@page-change="onPageChange">
@page-change="onPageChange" :data-id="props.row.id">
<div>
<router-link :to="{name: 'subscribers_list', params: { listID: props.row.id }}">
{{ props.row.name }}
@ -36,20 +36,22 @@
</div>
</b-table-column>
<b-table-column field="type" :label="$t('globals.fields.type')" sortable>
<b-table-column field="type" :label="$t('globals.fields.type')" header-class="cy-type"
sortable>
<div>
<b-tag :class="props.row.type">
<b-tag :class="props.row.type" :data-cy="`type-${props.row.type}`">
{{ $t('lists.types.' + props.row.type) }}
</b-tag>
{{ ' ' }}
<b-tag>
<b-tag :data-cy="`optin-${props.row.optin}`">
<b-icon :icon="props.row.optin === 'double' ?
'account-check-outline' : 'account-off-outline'" size="is-small" />
{{ ' ' }}
{{ $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))">
href="#" @click="$utils.confirm(null, () => createOptinCampaign(props.row))"
data-cy="btn-send-optin-campaign">
<b-tooltip :label="$t('lists.sendOptinCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
{{ $t('lists.sendOptinCampaign') }}
@ -58,33 +60,35 @@
</div>
</b-table-column>
<b-table-column field="subscriber_count" :label="$t('globals.terms.lists')"
numeric sortable centered>
<b-table-column field="subscriber_count" :label="$t('globals.terms.subscribers')"
header-class="cy-subscribers" 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="$t('globals.fields.createdAt')" sortable>
<b-table-column field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')" sortable>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" 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}`">
<router-link :to="`/campaigns/new?list_id=${props.row.id}`" data-cy="btn-campaign">
<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)">
<a href="" @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<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)">
<a href="" @click.prevent="deleteList(props.row)" data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
@ -192,7 +196,7 @@ export default Vue.extend({
name: this.$t('lists.optinTo', { name: list.name }),
subject: this.$t('lists.confirmSub', { name: list.name }),
lists: [list.id],
from_email: this.serverConfig.fromEmail,
from_email: this.settings['app.from_email'],
content_type: 'richtext',
messenger: 'email',
type: 'optin',
@ -206,7 +210,7 @@ export default Vue.extend({
},
computed: {
...mapState(['serverConfig', 'loading', 'lists']),
...mapState(['loading', 'lists', 'settings']),
},
mounted() {

View File

@ -3,7 +3,7 @@
<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>
<span class="has-text-grey-light"> / {{ settings['upload.provider'] }}</span>
</h1>
<b-loading :active="isProcessing || loading.media"></b-loading>
@ -141,7 +141,7 @@ export default Vue.extend({
},
computed: {
...mapState(['media', 'serverConfig', 'loading']),
...mapState(['loading', 'media', 'settings']),
isProcessing() {
if (this.toUpload > 0 && this.uploaded < this.toUpload) {

View File

@ -1,6 +1,6 @@
<template>
<section class="settings">
<b-loading :is-full-page="true" v-if="isLoading" active />
<b-loading :is-full-page="true" v-if="loading.settings || isLoading" active />
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">{{ $t('settings.title') }}</h1>
@ -8,7 +8,9 @@
<div class="column has-text-right">
<b-button :disabled="!hasFormChanged"
type="is-primary" icon-left="content-save-outline"
@click="onSubmit" class="isSaveEnabled">{{ $t('globals.buttons.save') }}</b-button>
@click="onSubmit" class="isSaveEnabled" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</div>
</header>
<hr />
@ -278,11 +280,11 @@
<div class="column is-2">
<b-field :label="$t('globals.buttons.enabled')">
<b-switch v-model="item.enabled" name="enabled"
:native-value="true" />
:native-value="true" data-cy="btn-enable-smtp" />
</b-field>
<b-field v-if="form.smtp.length > 1">
<a @click.prevent="$utils.confirm(null, () => removeSMTP(n))"
href="#" class="is-size-7">
href="#" class="is-size-7" data-cy="btn-delete-smtp">
<b-icon icon="trash-can-outline" size="is-small" />
{{ $t('globals.buttons.delete') }}
</a>
@ -528,8 +530,6 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import store from '../store';
import { models } from '../constants';
const dummyPassword = ' '.repeat(8);
@ -537,7 +537,7 @@ export default Vue.extend({
data() {
return {
regDuration: '[0-9]+(ms|s|m|h|d)',
isLoading: true,
isLoading: false,
// formCopy is a stringified copy of the original settings against which
// form is compared to detect changes.
@ -635,11 +635,11 @@ export default Vue.extend({
this.isLoading = true;
this.$api.updateSettings(form).then((data) => {
if (data.needsRestart) {
// Update the 'needsRestart' flag on the global serverConfig state
// as there are running campaigns and the app couldn't auto-restart.
store.commit('setModelResponse',
{ model: models.serverConfig, data: { ...this.serverConfig, needsRestart: true } });
// There are running campaigns and the app didn't auto restart.
// The UI will show a warning.
this.$root.loadConfig();
this.getSettings();
this.isLoading = false;
return;
}
@ -650,8 +650,8 @@ export default Vue.extend({
const pollId = setInterval(() => {
this.$api.getHealth().then(() => {
clearInterval(pollId);
this.$root.loadConfig();
this.getSettings();
this.$reloadServerConfig();
});
}, 500);
}, () => {
@ -666,7 +666,7 @@ export default Vue.extend({
for (let i = 0; i < d.smtp.length; i += 1) {
d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4);
// The backend doesn't send passwords, so add a dummy so that it
// The backend doesn't send passwords, so add a dummy so that
// the password looks filled on the UI.
d.smtp[i].password = dummyPassword;
}

View File

@ -8,16 +8,19 @@
<section expanded class="modal-card-body">
<b-field label="Action">
<div>
<b-radio v-model="form.action" name="action" native-value="add">
<b-radio v-model="form.action" name="action" native-value="add"
data-cy="check-list-add">
{{ $t('globals.buttons.add') }}
</b-radio>
<b-radio v-model="form.action" name="action" native-value="remove">
<b-radio v-model="form.action" name="action" native-value="remove"
data-cy="check-list-remove">
{{ $t('globals.buttons.remove') }}
</b-radio>
<b-radio
v-model="form.action"
name="action"
native-value="unsubscribe"
data-cy="check-list-unsubscribe"
>{{ $t('subscribers.markUnsubscribed') }}</b-radio>
</div>
</b-field>

View File

@ -8,24 +8,25 @@
<h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
<p v-if="isEditing" class="has-text-grey is-size-7">
{{ $t('globals.fields.id') }}: {{ data.id }} /
{{ $t('globals.fields.id') }}: <span data-cy="id">{{ data.id }}</span> /
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
</p>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" :ref="'focus'"
<b-input :maxlength="200" v-model="form.email" name="email" :ref="'focus'"
:placeholder="$t('subscribers.email')" required></b-input>
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name"
<b-input :maxlength="200" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')"></b-input>
</b-field>
<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>
<b-select v-model="form.status" name="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>
@ -42,7 +43,7 @@
<b-field :label="$t('subscribers.attribs')" label-position="on-border"
:message="$t('subscribers.attribsHelp') + ' ' + egAttribs">
<b-input v-model="form.strAttribs" type="textarea" />
<b-input v-model="form.strAttribs" name="attribs" type="textarea" />
</b-field>
<a href="https://listmonk.app/docs/concepts"
target="_blank" rel="noopener noreferrer" class="is-size-7">
@ -97,10 +98,13 @@ export default Vue.extend({
},
createSubscriber() {
const attribs = this.validateAttribs(this.form.strAttribs);
let attribs = {};
if (this.form.strAttribs) {
attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
}
const data = {
email: this.form.email,
@ -124,10 +128,13 @@ export default Vue.extend({
},
updateSubscriber() {
const attribs = this.validateAttribs(this.form.strAttribs);
let attribs = {};
if (this.form.strAttribs) {
attribs = this.validateAttribs(this.form.strAttribs);
if (!attribs) {
return;
}
}
const data = {
id: this.form.id,
@ -158,7 +165,7 @@ export default Vue.extend({
attribs = JSON.parse(str);
} catch (e) {
this.$buefy.toast.open({
message: `${this.$t('subscribers.invalidJSON')}: e.toString()`,
message: `${this.$t('subscribers.invalidJSON')}: ${e.toString()}`,
type: 'is-danger',
duration: 3000,
queue: false,

View File

@ -3,14 +3,16 @@
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">{{ $t('globals.terms.subscribers') }}
<span v-if="!isNaN(subscribers.total)">({{ subscribers.total }})</span>
<span v-if="!isNaN(subscribers.total)">
(<span data-cy="count">{{ subscribers.total }}</span>)
</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
</span>
</h1>
</div>
<div class="column has-text-right">
<b-button type="is-primary" icon-left="plus" @click="showNewForm">
<b-button type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</div>
@ -23,13 +25,13 @@
<b-field grouped>
<b-input @input="onSimpleQueryInput" v-model="queryInput"
:placeholder="$t('subscribers.queryPlaceholder')" icon="magnify" ref="query"
:disabled="isSearchAdvanced"></b-input>
:disabled="isSearchAdvanced" data-cy="search"></b-input>
<b-button native-type="submit" type="is-primary" icon-left="magnify"
:disabled="isSearchAdvanced"></b-button>
:disabled="isSearchAdvanced" data-cy="btn-search"></b-button>
</b-field>
<p>
<a href="#" @click.prevent="toggleAdvancedSearch">
<a href="#" @click.prevent="toggleAdvancedSearch" data-cy="btn-advanced-search">
<b-icon icon="cog-outline" size="is-small" />
{{ $t('subscribers.advancedQuery') }}
</a>
@ -40,7 +42,8 @@
<b-input v-model="queryParams.queryExp"
@keydown.native.enter="onAdvancedQueryEnter"
type="textarea" ref="queryExp"
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'">
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blocklisted'"
data-cy="query">
</b-input>
</b-field>
<b-field>
@ -55,8 +58,9 @@
<div class="buttons">
<b-button native-type="submit" type="is-primary"
icon-left="magnify">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel">
icon-left="magnify" data-cy="btn-query">{{ $t('subscribers.query') }}</b-button>
<b-button @click.prevent="toggleAdvancedSearch" icon-left="cancel"
data-cy="btn-query-reset">
{{ $t('subscribers.reset') }}
</b-button>
</div>
@ -80,15 +84,15 @@
</p>
<p class="actions">
<a href='' @click.prevent="showBulkListForm">
<a href='' @click.prevent="showBulkListForm" data-cy="btn-manage-lists">
<b-icon icon="format-list-bulleted-square" size="is-small" /> Manage lists
</a>
<a href='' @click.prevent="deleteSubscribers">
<a href='' @click.prevent="deleteSubscribers" data-cy="btn-delete-subscribers">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<a href='' @click.prevent="blocklistSubscribers">
<a href='' @click.prevent="blocklistSubscribers" data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
</a>
</p><!-- selection actions //-->
@ -110,7 +114,8 @@
</a>
</template>
<template slot-scope="props">
<b-table-column field="status" :label="$t('globals.fields.status')" sortable>
<b-table-column field="status" :label="$t('globals.fields.status')"
header-class="cy-status" :data-id="props.row.id" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">
@ -119,55 +124,62 @@
</a>
</b-table-column>
<b-table-column field="email" :label="$t('subscribers.email')" sortable>
<b-table-column field="email" :label="$t('subscribers.email')"
header-class="cy-email" sortable>
<a :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)">
{{ props.row.email }}
</a>
<b-taglist>
<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">
<template v-for="l in props.row.lists">
<router-link :to="`/subscribers/lists/${l.id}`"
v-bind:key="l.id" style="padding-right:0.5em;">
<b-tag :class="l.subscriptionStatus" size="is-small" :key="l.id">
{{ l.name }}
<sup>{{ $t('subscribers.status.'+ l.subscriptionStatus) }}</sup>
</b-tag>
</router-link>
</template>
</b-taglist>
</b-table-column>
<b-table-column field="name" :label="$t('globals.fields.name')" sortable>
<b-table-column field="name" :label="$t('globals.fields.name')"
header-class="cy-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="$t('globals.terms.lists')" numeric centered>
<b-table-column field="lists" :label="$t('globals.terms.lists')"
header-class="cy-lists" numeric centered>
{{ listCount(props.row.lists) }}
</b-table-column>
<b-table-column field="created_at" :label="$t('globals.fields.createdAt')" sortable>
<b-table-column field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')" sortable>
<b-table-column field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" 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`">
<a :href="`/api/subscribers/${props.row.id}/export`" data-cy="btn-download">
<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)">
@click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<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)">
<a href='' @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
@ -243,7 +255,7 @@ export default Vue.extend({
methods: {
// Count the lists from which a subscriber has not unsubscribed.
listCount(lists) {
return lists.reduce((defVal, item) => (defVal + item.status !== 'unsubscribed' ? 1 : 0), 0);
return lists.reduce((defVal, item) => (defVal + (item.subscriptionStatus !== 'unsubscribed' ? 1 : 0)), 0);
},
toggleAdvancedSearch() {

View File

@ -12,11 +12,11 @@
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name"
placeholder="$t('globals.fields.name')" required></b-input>
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
:placeholder="$t('globals.fields.name')" required />
</b-field>
<b-field :label="$t('globals.fields.rawHTML')" label-position="on-border">
<b-field :label="$t('templates.rawHTML')" label-position="on-border">
<b-input v-model="form.body" type="textarea" required />
</b-field>

View File

@ -32,31 +32,34 @@
<b-table-column class="actions" align="right">
<div>
<a href="#" @click.prevent="previewTemplate(props.row)">
<a href="#" @click.prevent="previewTemplate(props.row)" data-cy="btn-preview">
<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)">
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<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))">
(name) => cloneTemplate(name, props.row))"
data-cy="btn-clone">
<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))">
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"
data-cy="btn-set-default">
<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))">
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"
data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>

665
frontend/yarn.lock vendored

File diff suppressed because it is too large Load Diff

7
go.mod
View File

@ -3,6 +3,7 @@ module github.com/knadh/listmonk
go 1.13
require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/disintegration/imaging v1.6.2
github.com/gofrs/uuid v3.2.0+incompatible
@ -16,14 +17,16 @@ require (
github.com/labstack/gommon v0.3.0 // indirect
github.com/lib/pq v1.3.0
github.com/mailru/easyjson v0.7.6
github.com/nats-io/nats-server/v2 v2.1.7 // indirect
github.com/mitchellh/copystructure v1.1.2 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/rhnvrm/simples3 v0.5.0
github.com/spf13/pflag v1.0.5
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/goldmark v1.3.4 // indirect
golang.org/x/mod v0.3.0
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195
)
replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.8

94
go.sum
View File

@ -1,59 +1,48 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=
github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ=
github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195 h1:j0UEFmS7wSjAwKEIkgKBn8PRDfjcuggzr93R9wk53nQ=
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/knadh/email v0.0.0-20200206100304-6d2c7064c2e8 h1:HVq7nA5uWjpo93WsWjva1YIBuQrr8UkWQEUbzg1DX+E=
github.com/knadh/email v0.0.0-20200206100304-6d2c7064c2e8/go.mod h1:Fy2gCFfZhay8jplf/Csj6cyH/oshQTkLQYZbKkcV+SY=
github.com/knadh/goyesql v2.0.0+incompatible h1:hJFJrU8kaiLmvYt9I/1k1AB7q+qRhHs/afzTfQ3eGqk=
github.com/knadh/goyesql v2.0.0+incompatible/go.mod h1:W0tSzU8l7lYH1Fihj+bdQzkzOwvirrsMNHwkuY22qoY=
github.com/knadh/goyesql/v2 v2.1.1 h1:Orp5ldaxPM4ozKHfu1m7p6iolJFXDGOpF3/jyOgO6ls=
github.com/knadh/goyesql/v2 v2.1.1/go.mod h1:pMzCA130/ZhEIoMmSmbEFXor3A2dxl5L+JllAc/l64s=
github.com/knadh/koanf v0.8.1 h1:4VLACWqrkWRQIup3ooq6lOnaSbOJSNO+YVXnJn/NPZ8=
github.com/knadh/koanf v0.8.1/go.mod h1:kVvmDbXnBtW49Czi4c1M+nnOWF0YSNZ8BaKvE/bCO1w=
github.com/knadh/koanf v0.12.0 h1:xQo0Y43CbzOix0tTeE+plIcfs1pTuaUI1/SsvDl2ROI=
github.com/knadh/koanf v0.12.0/go.mod h1:31bzRSM7vS5Vm9LNLo7B2Re1zhLOZT6EQKeodixBikE=
github.com/knadh/smtppool v0.1.1 h1:pSi1Gc5TXOaN/Z/YiqfZbk/vd9dqzXzAfQiss0QSGQU=
github.com/knadh/smtppool v0.1.1/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
github.com/knadh/smtppool v0.2.0 h1:+llTWRljNIVg05MMu9TiefELTNwblexjsd1ALAPXZUs=
github.com/knadh/smtppool v0.2.0/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
github.com/knadh/smtppool v0.2.1 h1:J97q7s6bOsyexHPQRsV4GBX+Ru5dXKXhpZaaS5leu4E=
github.com/knadh/smtppool v0.2.1/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA=
github.com/knadh/stuffbin v1.0.0 h1:NQon6PTpLXies4bRFhS3VpLCf6y+jn6YVXU3i2wPQ+M=
github.com/knadh/stuffbin v1.0.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/knadh/stuffbin v1.1.0 h1:f5S5BHzZALjuJEgTIOMC9NidEnBJM7Ze6Lu1GHR/lwU=
github.com/knadh/stuffbin v1.1.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -63,7 +52,6 @@ github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -78,76 +66,64 @@ github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+tw
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.1.2 h1:Th2TIvG1+6ma3e/0/bopBKohOTY7s4dA8V2q4EUcBJ0=
github.com/mitchellh/copystructure v1.1.2/go.mod h1:EBArHfARyrSWO/+Wyr9zwEkc6XMFB9XyNgFNmRkZZU4=
github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4=
github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nats-io/jwt v0.3.2 h1:+RB5hMpXUUA2dfxuhBTEkMOrYmM+gKIZYS1KjSostMI=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
github.com/nats-io/nats-server v1.4.1 h1:Ul1oSOGNV/L8kjr4v6l2f9Yet6WY+LevH1/7cRZ/qyA=
github.com/nats-io/nats-server/v2 v2.1.7 h1:jCoQwDvRYJy3OpOTHeYfvIPLP46BMeDmH7XEJg/r42I=
github.com/nats-io/nats-server/v2 v2.1.7/go.mod h1:rbRrRE/Iv93O/rUvZ9dh4NfT0Cm9HWjW/BqOWLGgYiE=
github.com/nats-io/nats.go v1.10.0/go.mod h1:AjGArbfyR50+afOUotNX2Xs5SYHf+CoOa5HH1eEl2HE=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.4 h1:aEsHIssIk6ETN5m2/MD8Y4B2X7FfXrBAUdkyRvbVYzA=
github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE=
github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rhnvrm/simples3 v0.5.0 h1:X+WX0hqoKScdoJAw/G3GArfZ6Ygsn8q+6MdocTMKXOw=
github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
github.com/yuin/goldmark v1.3.4 h1:pd9FbZYGoTk0XaRHfu9oRrAiD8F5/MVZ1aMgLK2+S/w=
github.com/yuin/goldmark v1.3.4/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24 h1:R8bzl0244nw47n1xKs1MUMAaTNgjavKcN/aX2Ss3+Fo=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@ -155,23 +131,13 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195 h1:tj3Wzc08ekoAl8zEsLhT+5EmZ9TE/qpTTTi4oZjOPMw=
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4=

View File

@ -2,10 +2,11 @@
"_.code": "de",
"_.name": "Deutsch (de)",
"admin.errorMarshallingConfig": "Fehler beim einlesen der Konfigration: {error}",
"campaigns.addAltText": "Add alternate plain text message",
"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.confirmSchedule": "Diese Kampagne startet zu einem konfigurierten Zeitpunkt. Jetzt starten?",
"campaigns.confirmSwitchFormat": "Du wirst die Formatierung des Inhalts vielleicht verlieren. Fortfahren?",
"campaigns.content": "Inhalt",
"campaigns.contentHelp": "Inhalt hier",
@ -13,8 +14,8 @@
"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.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}.",
@ -24,7 +25,8 @@
"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.markdown": "Markdown",
"campaigns.needsSendAt": "Die Kampagne 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.",
@ -42,6 +44,7 @@
"campaigns.progress": "Fortschritt",
"campaigns.queryPlaceholder": "Name oder Betreff",
"campaigns.rawHTML": "HTML Code",
"campaigns.removeAltText": "Remove alternate plain text message",
"campaigns.richText": "Rich text",
"campaigns.schedule": "Kampagne planen",
"campaigns.scheduled": "geplant",
@ -71,13 +74,13 @@
"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.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.confirmSubWelcome": "Hallo",
"email.optin.privateList": "Private Liste",
"email.status.campaignReason": "Grund",
"email.status.campaignSent": "Gesendet",
@ -89,10 +92,12 @@
"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.formHTMLHelp": "Benutze den folgenden HTML Code um das Formular zum Anmelden auf einer externen Seite anzuseigen. Das Formular sollte das `email` Feld und eins oder mehr `l` (Listen UUID) Felder enthalten. `name` ist optional.",
"forms.noPublicLists": "There are no public lists to generate a forms.",
"forms.publicLists": "Öffentliche Listen",
"forms.selectHelp": "Wähle die Listen die du zum Formulat hinzufügen möchtest.",
"forms.title": "Formulate",
"forms.publicSubPage": "Public subscription page",
"forms.selectHelp": "Wähle die Listen die du zum Formular hinzufügen möchtest.",
"forms.title": "Formulare",
"globals.buttons.add": "Hinzufügen",
"globals.buttons.addNew": "Neu hinzufügen",
"globals.buttons.cancel": "Abbrechen",
@ -108,13 +113,13 @@
"globals.buttons.remove": "Entfernen",
"globals.buttons.save": "Speichern",
"globals.buttons.saveChanges": "Änderungen speichern",
"globals.days.0": "So",
"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",
@ -126,11 +131,11 @@
"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.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",
@ -165,13 +170,13 @@
"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.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.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",
@ -180,8 +185,8 @@
"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.invalidParams": "Ungültiger Parameter: {error}",
"import.listSubHelp": "Listen die abonniert werden.",
"import.mode": "Mode",
"import.overwrite": "Überschreiben?",
"import.overwriteHelp": "Überschreibe Name und Attribute von bestehenden Abonnenten?",
@ -202,14 +207,14 @@
"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.typeHelp": "Öffentliche Listen können von allen abonniert werden. Die Namen der Abonnenten könnten auf einer öffentlichen Seite, wie der Verwaltungsseite auftauchen.",
"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.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})",
@ -237,12 +242,14 @@
"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.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.noListsAvailable": "Keine Listen zum Abonnieren verfügbar.",
"public.noListsSelected": "Keine Liste zum Abonnieren ausgewählt.",
"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?",
@ -251,10 +258,14 @@
"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.sub": "Abonnieren",
"public.subConfirmed": "Abonnement erfolgreich",
"public.subConfirmedTitle": "Bestätigt",
"public.subName": "Name (optional)",
"public.subNotFound": "Abonnement nicht gefunden.",
"public.subOptinPending": "Dir wurde eine E-Mail zur Bestätigung geschickt.",
"public.subPrivateList": "Private Liste",
"public.subTitle": "Abonnieren",
"public.unsub": "Abmelden",
"public.unsubFull": "Auch von allen zukünftigen E-Mails abmelden.",
"public.unsubHelp": "Möchtest du dich von der Liste abmelden?",
@ -262,11 +273,14 @@
"public.unsubbedInfo": "Du wurdest erfolgreich abgemeldet",
"public.unsubbedTitle": "Abgemeldet",
"public.unsubscribeTitle": "Von einer Liste abmelden.",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"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.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) komplette URL für ein statisches Favicon für die angezeigten Seiten (wie Abmelden).",
"settings.general.fromEmail": "Standard `von` E-Mail",
@ -310,6 +324,7 @@
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL des Postback servers.",
"settings.messengers.username": "Benutzername",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"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",
@ -336,6 +351,7 @@
"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.restart": "Restart",
"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\"}]",
@ -364,6 +380,7 @@
"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",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Erweitert",
"subscribers.advancedQueryHelp": "Partieller SQL Ausdruck um Attribute der Abonnenten abzufragen",
"subscribers.attribs": "Attribute",
@ -376,11 +393,11 @@
"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.errorInvalidIDs": "Eine oder mehrere 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.errorPreparingQuery": "Fehler beim Vorbereiten der Abonnentenabfrage: {error}",
"subscribers.errorSendingOptin": "Fehler beim Senden der Opt-In E-Mail",
"subscribers.export": "Export",
"subscribers.invalidAction": "Ungültiger Vorgang",
"subscribers.invalidEmail": "Ungültige E-Mail",
@ -400,6 +417,7 @@
"subscribers.reset": "Zurücksetzen",
"subscribers.selectAll": "Wähle alle {num}",
"subscribers.status.blocklisted": "Blockiert",
"subscribers.status.confirmed": "Confirmed",
"subscribers.status.enabled": "Aktiviert",
"subscribers.status.subscribed": "Angemeldet",
"subscribers.status.unconfirmed": "Bestätigung ausstehend",
@ -409,7 +427,7 @@
"templates.default": "Standard",
"templates.dummyName": "Test Kampagne",
"templates.dummySubject": "Test Kampagnen name",
"templates.errorCompiling": "Fehler beim kompilieren des Templates: {error}",
"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",

View File

@ -25,6 +25,7 @@
"campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
"campaigns.newCampaign": "New campaign",
"campaigns.noKnownSubsToTest": "No known subscribers to test.",
@ -79,7 +80,7 @@
"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.confirmSubWelcome": "Hi",
"email.optin.privateList": "Private list",
"email.status.campaignReason": "Reason",
"email.status.campaignSent": "Sent",
@ -92,6 +93,7 @@
"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.",
@ -111,13 +113,13 @@
"globals.buttons.remove": "Remove",
"globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes",
"globals.days.0": "Sun",
"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",
@ -257,12 +259,13 @@
"public.privacyWipe": "Wipe your data",
"public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.",
"public.sub": "Subscribe",
"public.subTitle": "Subscribe",
"public.subName": "Name (optional)",
"public.subConfirmed": "Subscribed successfully.",
"public.subConfirmedTitle": "Confirmed",
"public.subName": "Name (optional)",
"public.subNotFound": "Subscription not found.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"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?",
@ -270,13 +273,14 @@
"public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled",
"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.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",
@ -320,6 +324,7 @@
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL of the Postback server.",
"settings.messengers.username": "Username",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"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",
@ -346,6 +351,7 @@
"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.restart": "Restart",
"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\"}]",
@ -374,6 +380,7 @@
"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",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Advanced",
"subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes",
"subscribers.attribs": "Attributes",

438
i18n/it.json Normal file
View File

@ -0,0 +1,438 @@
{
"_.code": "it",
"_.name": "Italiano (it)",
"admin.errorMarshallingConfig": "Errore durante la lettura della configurazione: {error}",
"campaigns.addAltText": "Aggiungere un messaggio sostitutivo in testo semplice",
"campaigns.cantUpdate": "Impossibile aggiornare una campagna in corso o già effettuata.",
"campaigns.clicks": "Clic",
"campaigns.confirmDelete": "Cancellare {nome}",
"campaigns.confirmSchedule": " Questa campagna inizierà automaticamente alla data e all'ora programmate. Programmare adesso?",
"campaigns.confirmSwitchFormat": "Il contenuto può perdere la sua formattazione. Continuare?",
"campaigns.content": "Contenuto",
"campaigns.contentHelp": "Contenuto qui",
"campaigns.continue": "Continuare",
"campaigns.copyOf": "Copie di {name}",
"campaigns.dateAndTime": "Data e ora",
"campaigns.ended": "Finito",
"campaigns.errorSendTest": "Errore durante il test di invio: {error}",
"campaigns.fieldInvalidBody": "Errore durante la compilazione del contenuto della campagna: {error}",
"campaigns.fieldInvalidFromEmail": "`Mittente` non valido.",
"campaigns.fieldInvalidListIDs": "ID della lista non valido.",
"campaigns.fieldInvalidMessenger": "Strumento di messaggeria sconosciuto {name}.",
"campaigns.fieldInvalidName": "Lunghezza del nome non valida.",
"campaigns.fieldInvalidSendAt": "La data programmata deve essere futura.",
"campaigns.fieldInvalidSubject": "Lunghezza dell'oggetto non valida.",
"campaigns.fromAddress": "Mittente",
"campaigns.fromAddressPlaceholder": "Tuo nome <noreply@tuosito.com>",
"campaigns.invalid": "Campagna non valida",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "È necessaria una data per programmare la campagna.",
"campaigns.newCampaign": "Nuova campagna",
"campaigns.noKnownSubsToTest": "Nessun iscritto conosciuto da testare.",
"campaigns.noOptinLists": "Nessuna lista opt-in trovata per poter creare una campagna.",
"campaigns.noSubs": "Non esiste alcun iscritto nelle liste selezionate per creare la campagna.",
"campaigns.noSubsToTest": "Non c'è alcun iscritto a cui rivolgersi.",
"campaigns.notFound": "Campagna introvabile.",
"campaigns.onlyActiveCancel": "Solo le campagne attive possono essere annullate.",
"campaigns.onlyActivePause": "Solo le campagne attive possono essere messe in pausa.",
"campaigns.onlyDraftAsScheduled": "Solo le bozze delle campagne possono essere programmate.",
"campaigns.onlyPausedDraft": "Solo le bozze e le campagne in pausa possono essere lanciate.",
"campaigns.onlyScheduledAsDraft": "Solo le campagne pianificate possono essere registrate come bozze.",
"campaigns.pause": "Pausa",
"campaigns.plainText": "Testo semplice",
"campaigns.preview": "Anteprima",
"campaigns.progress": "Avanzamento",
"campaigns.queryPlaceholder": "Nome o oggetto",
"campaigns.rawHTML": "HTML semplice",
"campaigns.removeAltText": "Cancellare il messaggio sostitutivo in testo semplice",
"campaigns.richText": "Testo formattato",
"campaigns.schedule": "Programmare la campagna",
"campaigns.scheduled": "Programmata",
"campaigns.send": "Inviare",
"campaigns.sendLater": "Inviare più tardi",
"campaigns.sendTest": "Inviare un messaggio di testo",
"campaigns.sendTestHelp": "Per aggiungere più destinatari, premi Enter dopo aver aggiunto un indirizzo. Gli indirizzi devono appartenere a iscritti esistenti.",
"campaigns.sendToLists": "Liste da inviare a",
"campaigns.sent": "Inviato",
"campaigns.start": "Lanciare la campagna",
"campaigns.started": "\"{name}\" ha cominciato",
"campaigns.startedAt": "Cominciato",
"campaigns.stats": "Statistiche",
"campaigns.status.cancelled": "Annullato",
"campaigns.status.draft": "Bozza",
"campaigns.status.finished": "Finito",
"campaigns.status.paused": "In pausa",
"campaigns.status.running": "In corso",
"campaigns.status.scheduled": "Programmata",
"campaigns.statusChanged": "\"{name}\" e {status}",
"campaigns.subject": "Oggetto",
"campaigns.testEmails": "Emails di prova",
"campaigns.testSent": "Messaggio di prova inviato",
"campaigns.timestamps": "Marcatura temporale ",
"campaigns.views": "Visualizzazioni",
"dashboard.campaignViews": "Visualizzazioni della campagna",
"dashboard.linkClicks": "Clic sui link",
"dashboard.messagesSent": "Messaggi inviati",
"dashboard.orphanSubs": "Orfani",
"email.data.info": "È stato aggiunto un file JSON contenente l'insieme dei tuoi dati salvati. Può essere visualizzato in un editore di testo.",
"email.data.title": "I tuoi dati",
"email.optin.confirmSub": "Confermare l'iscrizione",
"email.optin.confirmSubHelp": "Conferma la tua iscrizione cliccando sul pulsante qui sotto.",
"email.optin.confirmSubInfo": "Sei stato aggiunto alle liste seguenti:",
"email.optin.confirmSubTitle": "Confermare l'iscrizione",
"email.optin.confirmSubWelcome": "Buongiorno",
"email.optin.privateList": "Lista privata",
"email.status.campaignReason": "Ragione",
"email.status.campaignSent": "Inviato",
"email.status.campaignUpdateTitle": "Aggiornamento della campagna",
"email.status.importFile": "File",
"email.status.importRecords": "Salvataggi",
"email.status.importTitle": "Importare l'aggiornamento",
"email.status.status": "Stato",
"email.unsub": "Cancella iscrizione",
"email.unsubHelp": "Non desideri ricevere queste mail?",
"forms.formHTML": "Formulario HTML",
"forms.formHTMLHelp": "Utilizza il seguente codice HTML per visualizzare un formulario d'abbonamento su una pagina Web esterna. Il formulario deve avere il campo email e uno o più campi `l` (liste UUID). Il campo nome è facoltativo.",
"forms.noPublicLists": "Non ci sono liste pubbliche per generare un formulario.",
"forms.publicLists": "Liste pubbliche",
"forms.publicSubPage": "Pagina di iscrizione pubblica",
"forms.selectHelp": "Seleziona le liste da aggiungere al formulario.",
"forms.title": "Formulari",
"globals.buttons.add": "Aggiungi",
"globals.buttons.addNew": "Aggiungi nuovo",
"globals.buttons.cancel": "Annulla",
"globals.buttons.clone": "Clona",
"globals.buttons.close": "Chiudi",
"globals.buttons.continue": "Continuare",
"globals.buttons.delete": "Cancellare",
"globals.buttons.edit": "Modifica",
"globals.buttons.enabled": "Attivata",
"globals.buttons.learnMore": "Per saperne di più",
"globals.buttons.new": "Nuovo",
"globals.buttons.ok": "Ok",
"globals.buttons.remove": "Cancellare",
"globals.buttons.save": "Salvare",
"globals.buttons.saveChanges": "Salvare le modifiche",
"globals.days.0": "dom",
"globals.days.1": "lun",
"globals.days.2": "mar",
"globals.days.3": "mer",
"globals.days.4": "gio",
"globals.days.5": "ven",
"globals.days.6": "sab",
"globals.fields.createdAt": "Creato il ",
"globals.fields.id": "ID",
"globals.fields.name": "Nome",
"globals.fields.status": "Stato",
"globals.fields.type": "Tipo",
"globals.fields.updatedAt": "Aggiornato il",
"globals.fields.uuid": "UUID",
"globals.messages.confirm": "Sei sicuro?",
"globals.messages.created": "\"{name}\" creato",
"globals.messages.deleted": "\"{name}\" cancellato",
"globals.messages.emptyState": "Niente da visualizzare",
"globals.messages.errorCreating": "Errore durante la creazione di {name}: {error}",
"globals.messages.errorDeleting": "Errore durante la cancellazione di {name}: {error}",
"globals.messages.errorFetching": "Errore durante il recupero di {name}: {error}",
"globals.messages.errorUUID": "Errore durante la generazione dell'UUID: {error}",
"globals.messages.errorUpdating": "Errore durante l'aggiornamento di {name}: {error}",
"globals.messages.invalidID": "ID non valido",
"globals.messages.invalidUUID": "UUID non valido",
"globals.messages.notFound": "{name} introvabile",
"globals.messages.passwordChange": "Inserisci un valore da modificare",
"globals.messages.updated": "\"{name}\" aggiornato",
"globals.months.1": "Gen",
"globals.months.10": "Ott",
"globals.months.11": "Nov",
"globals.months.12": "Dic",
"globals.months.2": "Feb",
"globals.months.3": "Mar",
"globals.months.4": "Apr",
"globals.months.5": "Mag",
"globals.months.6": "Giu",
"globals.months.7": "Lug",
"globals.months.8": "Ago",
"globals.months.9": "Set",
"globals.terms.campaign": "Campagna | Campagne",
"globals.terms.campaigns": "Campagne",
"globals.terms.dashboard": "Tabella di marcia",
"globals.terms.list": "Lista | Liste",
"globals.terms.lists": "Liste",
"globals.terms.media": "Media | Media",
"globals.terms.messenger": "Strumento di messaggeria | Strumenti di messaggeria",
"globals.terms.messengers": "Strumento di messaggeria",
"globals.terms.settings": "Parametri",
"globals.terms.subscriber": "Iscritto | Iscritti",
"globals.terms.subscribers": "Iscritti",
"globals.terms.tag": "Etichetta | Etichette",
"globals.terms.tags": "Etichette",
"globals.terms.template": "Modello | Modelli",
"globals.terms.templates": "Modelli",
"import.alreadyRunning": "Un'importazione è già in corso. Aspetta che finisca o interrompila prima di riprovare.",
"import.blocklist": "Lista degli indirizzi bloccati",
"import.csvDelim": "Delimitatore CSV",
"import.csvDelimHelp": "Il delimitatore predefinito è la virgola.",
"import.csvExample": "Esempio di CSV semplice",
"import.csvFile": "File CSV o ZIP",
"import.csvFileHelp": "Clicca o trascina qui un file CSV o ZIP",
"import.errorCopyingFile": "Errore durante la copia del file: {error}",
"import.errorProcessingZIP": "Errore durante il trattamento del file ZIP: {error}",
"import.errorStarting": "Errore durante l'avvio dell'importazione: {error}",
"import.importDone": "Finito",
"import.importStarted": "L'importazione è inziata",
"import.instructions": "Istruzioni",
"import.instructionsHelp": "Carica un file CSV o un file ZIP contenente un solo CSV per importare iscritti in massa. Il file CSV deve avere le seguenti intestazioni con i nomi delle colonne esatti. Gli attributi (facoltativi) devono essere delle stringhe JSON valide tra virgolette doppie.",
"import.invalidDelim": "Il delimitatore deve essere un singolo carattere.",
"import.invalidFile": "File non valido: {error}",
"import.invalidMode": "Modalità non valida",
"import.invalidParams": "Parametri non validi: {error}",
"import.listSubHelp": "Liste a cui iscriversi.",
"import.mode": "Modalità",
"import.overwrite": "Sovrascrivere?",
"import.overwriteHelp": "Sostituire il nome e gli attributi degli iscritti esistenti?",
"import.recordsCount": "{num} / {total} salvataggi",
"import.stopImport": "Interrompere l'importazione",
"import.subscribe": "Iscriversi",
"import.title": "Importare iscritti",
"import.upload": "Caricare",
"lists.confirmDelete": "Sei sicuro? Questo non cancella gli iscritti",
"lists.confirmSub": "Confermare gli iscritti di {name}",
"lists.invalidName": "Nome errato",
"lists.newList": "Nuova lista",
"lists.optin": "Iscrizione",
"lists.optinHelp": "Opt-in invio doppio di una mail a l'iscritto richiedendo la sua conferma. Per le liste opt-in doppio, le campagne sono inviate solo agli iscritti che hanno confermato.",
"lists.optinTo": "Attivare {name}",
"lists.optins.double": "Opt-in doppio",
"lists.optins.single": "Opt-in semplice",
"lists.sendCampaign": "Inviare la campagna",
"lists.sendOptinCampaign": "Inviare una campagna opt-in",
"lists.type": "Tipo",
"lists.typeHelp": "Le liste pubbliche sono libere d'accesso in abbonamento e i loro nomi sono visibili sulle pagine pubbliche come ad esempio la pagina della gestione degli abbonamenti.",
"lists.types.private": "Privata",
"lists.types.public": "Pubblico",
"logs.title": "Giornali",
"media.errorReadingFile": "Errore di lettura del file: {error}",
"media.errorResizing": "Errore di ridimensionamento dell'immagine: {error}",
"media.errorSavingThumbnail": "Errore durante il salvataggio della vignetta: {error}",
"media.errorUploading": "Errore durante il caricamento del file: {error}",
"media.invalidFile": "File non valido: {error}",
"media.title": "Media",
"media.unsupportedFileType": "Tipo di file non supportato ({type})",
"media.upload": "Caricare",
"media.uploadHelp": "Seleziona o trascina qui una o più immagini",
"media.uploadImage": "Caricare l'immagine",
"menu.allCampaigns": "Tutte le campagne",
"menu.allLists": "Tutte le liste",
"menu.allSubscribers": "Tutti gli iscritti",
"menu.dashboard": "Tabella di marcia",
"menu.forms": "Formulari",
"menu.import": "Importare",
"menu.logs": "Giornali",
"menu.media": "Media",
"menu.newCampaign": "Creare nuovo",
"menu.settings": "Parametri",
"public.campaignNotFound": "Lista di diffusione impossibile da trovare.",
"public.confirmOptinSubTitle": "Confermare l'iscrizione",
"public.confirmSub": "Confermare l'iscrizione",
"public.confirmSubInfo": "Sei stato aggiunto alle liste seguenti:",
"public.confirmSubTitle": "Confermare",
"public.dataRemoved": "I tuoi abbonamenti e tutti i dati associati sono stati cancellati.",
"public.dataRemovedTitle": "Dati cancellati",
"public.dataSent": "I tuoi dati ti sono stati trasmessi via mail.",
"public.dataSentTitle": "Dati trasmessi via mail",
"public.errorFetchingCampaign": "Errore durante il recupero della mail.",
"public.errorFetchingEmail": "Messaggio mail impossibile da trovare",
"public.errorFetchingLists": "Errore durante il recupero delle liste. Per favore, riprova.",
"public.errorProcessingRequest": "Errore durante la gestione della richiesta. Per favore, riprova.",
"public.errorTitle": "Errore",
"public.invalidFeature": "Questa funzione non è disponibile.",
"public.invalidLink": "Link non valido",
"public.noListsAvailable": "Nessuna lista disponibile per l'iscrizione.",
"public.noListsSelected": "Nessuna lista valida selezionata per l'iscrizione.",
"public.noSubInfo": "Non ci sono iscrizioni da confermare.",
"public.noSubTitle": "Nessuna iscrizione",
"public.notFoundTitle": "Non trovato",
"public.privacyConfirmWipe": "Sei sicuro di voler cancellare in modo permanente tutti i tuoi dati d'iscrizione?",
"public.privacyExport": "Esporta i tuoi dati",
"public.privacyExportHelp": "Una copia dei tuoi dati ti sarà trasmessa via mail.",
"public.privacyTitle": "Privacy e dati",
"public.privacyWipe": "Cancella i tuoi dati",
"public.privacyWipeHelp": "Cancella in modo permanente tutte le tue iscrizioni e relativi dari dal database.",
"public.sub": "Iscriversi",
"public.subConfirmed": "Iscrizione avvenuta con successo.",
"public.subConfirmedTitle": "Confermato",
"public.subName": "Nome (facoltativo)",
"public.subNotFound": "Iscrizione impossibile da trovare.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subPrivateList": "Lista privata",
"public.subTitle": "Iscriversi",
"public.unsub": "Cancella iscrizione",
"public.unsubFull": "Cancella iscrizione anche per tutte le mail future.",
"public.unsubHelp": "Vuoi cancellare l'iscrizione da questa lista di diffusione?",
"public.unsubTitle": "Cancella iscrizione",
"public.unsubbedInfo": "La cancellazione è avvenuta con successo.",
"public.unsubbedTitle": "Iscrizione annullata",
"public.unsubscribeTitle": "Cancella l'iscrizione dalla lista di diffusione",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.duplicateMessengerName": "Nome in messaggeria doppio: {name}",
"settings.errorEncoding": "Errore durante la codifica dei parametri: {error}",
"settings.errorNoSMTP": "Devi attivare almeno un blocco SMTP",
"settings.general.adminNotifEmails": "Mail di notifica amministratore",
"settings.general.adminNotifEmailsHelp": "Lista indirizzi mail separati da virgole ai quali saranno inviate notifiche di amministrazione come gli aggiornamenti di importazione, la fine della campagna, eventuali problemi ecc.",
"settings.general.enablePublicSubPage": "Attiva la pagina di iscrizione pubblica",
"settings.general.enablePublicSubPageHelp": "Visualizza una pagina di iscrizione pubblica con tutte le liste pubbliche a cui è possibile iscriversi.",
"settings.general.faviconURL": "URL della favicon",
"settings.general.faviconURLHelp": "(Facoltativo) URL completo della favicon statica visibile dall'utente, come sulla pagina per annullare l'iscrizione.",
"settings.general.fromEmail": "Indirizzo mail `Mittente` predefinito",
"settings.general.fromEmailHelp": "Indirizzo mail `Mittente` nelle mail delle campagne uscenti visibile in modo predefinito. Questo parametro è modificabile per ogni campagna.",
"settings.general.language": "Lingua",
"settings.general.logoURL": "URL del logo",
"settings.general.logoURLHelp": "(Facoltativo) URL completo del logo statico visibile dall'utente come sulla pagina per annullare l'iscrizione.",
"settings.general.name": "Generale",
"settings.general.rootURL": "Radice dell'URL",
"settings.general.rootURLHelp": "URL pubblico dell'installazione (senza barra obliqua finale).",
"settings.invalidMessengerName": "Nome di messaggeria non valido.",
"settings.media.provider": "Fornitore",
"settings.media.s3.bucket": "Bucket",
"settings.media.s3.bucketPath": "Percorso del bucket",
"settings.media.s3.bucketPathHelp": "Percorso all'interno del bucket per caricare i file. Il valore predefinito è /",
"settings.media.s3.bucketType": "Tipo di bucket",
"settings.media.s3.bucketTypePrivate": "Privato",
"settings.media.s3.bucketTypePublic": "Pubblico",
"settings.media.s3.key": "Chiave d'accesso AWS",
"settings.media.s3.region": "Regione",
"settings.media.s3.secret": "Accesso segreto AWS",
"settings.media.s3.uploadExpiry": "Caricamento scaduto",
"settings.media.s3.uploadExpiryHelp": "(Facoltativo) Specifica il TTL (in secondi) per l'URL predefinito generato. Applicabile solo per i buckets privati (s, m, h, d per i secondi, minuti, ore e giorni).",
"settings.media.title": "Caricamento dei media",
"settings.media.upload.path": "Percorso del caricamento",
"settings.media.upload.pathHelp": "Percorso verso il repertorio dove i media saranno caricati.",
"settings.media.upload.uri": "URI del caricamento",
"settings.media.upload.uriHelp": "URI del caricamento che sarà visibile dal mondo esterno. Il media caricato nel percorso del caricamento sarà accessibile pubblicamente sotto {root_url}, per esempio: https://listmonk.tuosito.com/uploads.",
"settings.messengers.maxConns": "Nb. connessioni max.",
"settings.messengers.maxConnsHelp": "Numero massimo di connessioni simultanee al server.",
"settings.messengers.messageDiscard": "Annullare le modifiche?",
"settings.messengers.messageSaved": "Parametri salvati. Ricarica dell'applicazione...",
"settings.messengers.name": "Strumento di messaggeria",
"settings.messengers.nameHelp": "Per esempio: my-sms. Alfanumerico / trattino.",
"settings.messengers.password": "Password",
"settings.messengers.retries": "Tentativi",
"settings.messengers.retriesHelp": "Numero di tentativi in caso di errore invio messaggio.",
"settings.messengers.skipTLSHelp": "Ignora la verifica del nome dell'host sul certificato TLS.",
"settings.messengers.timeout": "Periodo di inattività",
"settings.messengers.timeoutHelp": "Tempo di attesa prima di una nuova attività sulla connessione prima della chiusura e cancellazione del pool (s per i secondi, m per i minuti).",
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Radice URL del server Postback.",
"settings.messengers.username": "Nome utente",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.performance.batchSize": "Dimensione del lotto",
"settings.performance.batchSizeHelp": "Numero di iscritti da estrarre dal database in una sola iterazione. Ogni iterazione estrae gli iscritti dal database, invia loro i messaggi, poi passa all'iterazione seguente per estrarre il lotto successivo. Idealmente questo valore dovrebbe essere superiore alla velocità massima possibile (Concorrenza x Frequenza del messaggio).",
"settings.performance.concurrency": "Concorrenza",
"settings.performance.concurrencyHelp": "Numero di worker (threads) concorrenti massimo che invieranno i messaggi contemporaneamente.",
"settings.performance.maxErrThreshold": "Soglia massima di errore",
"settings.performance.maxErrThresholdHelp": "Numero di errori (esempio: SMTP scaduto durante l'invio delle mail) che una campagna in corso può tollerare prima di essere sospesa per verifica o intervento manuale. Imposta sur 0 per non andare mai in pausa.",
"settings.performance.messageRate": "Frequenza del messaggio",
"settings.performance.messageRateHelp": "Numero massimo di messaggi a inviare per worker in un secondo. Se concorrente = 10 e frequenza del messaggio = 10, allora fino a 10x10 = 100 messaggi possono essere emessi ogni secondo. Questo parametro, come il parametro concorrente, dovrebbe essere modificato per mantenere i messaggi uscenti ogni secondo al di sotto del limite della velocità dei server dei messaggi destinatari.",
"settings.performance.name": "Performance",
"settings.performance.slidingWindow": "Attiva un limite tramite finestra scorrevole",
"settings.performance.slidingWindowDuration": "Durata",
"settings.performance.slidingWindowDurationHelp": "Durata del periodo della finestra scorrevole (m per minuto, h per ora).",
"settings.performance.slidingWindowHelp": "Limita il numero totale di messaggi inviati durante un dato periodo. Una volta raggiunto questo limite, l'invio dei messaggi è sospeso fino a che la finestra di tempo sia passata.",
"settings.performance.slidingWindowRate": "Num. max messaggi.",
"settings.performance.slidingWindowRateHelp": "Numero massimo di messaggi da inviare nella durata della finestra.",
"settings.privacy.allowBlocklist": "Autorizza la lista di blocco",
"settings.privacy.allowBlocklistHelp": "Autorizza gli iscritti a cancellare l'iscrizione da tutte le liste di diffusione e a segnalarsi come bloccati?",
"settings.privacy.allowExport": "Autorizza l'esportazione",
"settings.privacy.allowExportHelp": "Autorizzi gli iscritti a esportare i dati raccolti su di loro?",
"settings.privacy.allowWipe": "Autorizza la cancellazione",
"settings.privacy.allowWipeHelp": "Autorizza gli iscritti a cancellare le loro iscrizioni e tutti gli altri dati dal database. Le visualizzazioni della campagna e i clic sui link verranno anch'essi cancellati, mentre i contatori globali delle visualizzazioni e del numero di clic restano invariati (nessun iscritto vi è associato) in modo che le statistiche non siano compromesse.",
"settings.privacy.individualSubTracking": "Follow-up individuale degli abbonati",
"settings.privacy.individualSubTrackingHelp": "Monitora le visualizzazioni e i clic della campagna per iscritto. Quando è disabilitato, il follow-up delle visualizzazioni e dei clic, si effettua senza essere legato agli iscritti individuali.",
"settings.privacy.listUnsubHeader": "Includere l'intestazione `List-Unsubscribe`",
"settings.privacy.listUnsubHeaderHelp": "Includere intestazioni di annullamento dell'iscrizione che consentono agli utenti di annullare l'iscrizione con un clic dal proprio client di posta elettronica.",
"settings.privacy.name": "Vita privata",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "Protocollo di autenticazione",
"settings.smtp.customHeaders": "Intestazioni personalizzate",
"settings.smtp.customHeadersHelp": "Matrice facoltativa di intestazioni di posta elettronica da includere in tutti i messaggi inviati da questo server. Ad esempio: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Attivata",
"settings.smtp.heloHost": "Nome host HELO",
"settings.smtp.heloHostHelp": "Facoltativo. Alcuni server SMTP richiedono un nome di dominio completo nel nome host. Per impostazione predefinita, HELLOs viene fornito con `localhost`. Impostare questo parametro se deve essere utilizzato un nome host personalizzato.",
"settings.smtp.host": "Host",
"settings.smtp.hostHelp": "Indirizzo host del server SMTP.",
"settings.smtp.idleTimeout": "Periodo di inattività",
"settings.smtp.idleTimeoutHelp": "Tempo di attesa prima di una nuova attività sulla connessione prima della chiusura e cancellazione del pool (s per i secondi, m per i minuti).",
"settings.smtp.maxConns": "Nb. connessioni max.",
"settings.smtp.maxConnsHelp": "Numero massimo di connessioni simultanee al server SMTP.",
"settings.smtp.name": "SMTP",
"settings.smtp.password": "Password",
"settings.smtp.passwordHelp": "Entra per modificare",
"settings.smtp.port": "Porto",
"settings.smtp.portHelp": "Porta del server SMTP.",
"settings.smtp.retries": "Tentativi",
"settings.smtp.retriesHelp": "Numero di tentativi in caso di errore invio messaggio.",
"settings.smtp.setCustomHeaders": "Definisci intestazioni personalizzate",
"settings.smtp.skipTLS": "Ignora controllo TLS",
"settings.smtp.skipTLSHelp": "Ignora la verifica del nome dell'host sul certificato TLS.",
"settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Attiva STARTTLS.",
"settings.smtp.username": "Nome utente",
"settings.smtp.waitTimeout": "Tempo d'attesa",
"settings.smtp.waitTimeoutHelp": "Tempo di attesa per una nuova attività su una connessione prima che venga chiusa e rimossa dal pool (s per secondo, m per minuto).",
"settings.title": "Parametri",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Avanzate",
"subscribers.advancedQueryHelp": "Espressione SQL parziale per interrogare gli attributi del sottoscrittore",
"subscribers.attribs": "Attributi",
"subscribers.attribsHelp": "Gli attributi sono definiti come una mappa JSON, ad esempio:",
"subscribers.blocklistedHelp": "Gli abbonati bloccati non riceveranno mai e-mail.",
"subscribers.confirmBlocklist": "Lista di blocco {num} iscritto(i)?",
"subscribers.confirmDelete": "Elimina {num} iscrittoi(i)?",
"subscribers.confirmExport": "Esporta {num} iscritto(i)?",
"subscribers.downloadData": "Scarica i dati",
"subscribers.email": "Email",
"subscribers.emailExists": "Email già esistente.",
"subscribers.errorBlocklisting": "Errore durante il blocco degli iscritti: {error}",
"subscribers.errorInvalidIDs": "Una o più credenziali fornite non valide: {error}",
"subscribers.errorNoIDs": "Nessun ID fornito.",
"subscribers.errorNoListsGiven": "Nessuna lista fornita.",
"subscribers.errorPreparingQuery": "Errore durante la preparazione della richiesta dell'iscritto: {error}",
"subscribers.errorSendingOptin": "Errore durante l'invio dell'e-mail di attivazione.",
"subscribers.export": "Esportazione",
"subscribers.invalidAction": "Azione non valida.",
"subscribers.invalidEmail": "E-mail non valida.",
"subscribers.invalidJSON": "JSON non valido negli attributi.",
"subscribers.invalidName": "Nome errato.",
"subscribers.listChangeApplied": "Modifica della lista eseguita.",
"subscribers.lists": "Liste",
"subscribers.listsHelp": "Le liste i cui iscritti hanno annullato l'iscrizione non possono essere eliminate.",
"subscribers.listsPlaceholder": "Liste a cui iscriversi",
"subscribers.manageLists": "Gestisci liste",
"subscribers.markUnsubscribed": "Segna come non iscritto",
"subscribers.newSubscriber": "Nuovo iscritto",
"subscribers.numSelected": "{num} iscritto(i) selezionato(i)",
"subscribers.optinSubject": "Confermare l'iscrizione",
"subscribers.query": "Richiesta",
"subscribers.queryPlaceholder": "Email o nome",
"subscribers.reset": "Ripristina",
"subscribers.selectAll": "Seleziona tutto {num}",
"subscribers.status.blocklisted": "Lista bloccata",
"subscribers.status.confirmed": "Confermato",
"subscribers.status.enabled": "Attivata",
"subscribers.status.subscribed": "Iscritto",
"subscribers.status.unconfirmed": "Non confermato",
"subscribers.status.unsubscribed": "Iscrizione annullata",
"subscribers.subscribersDeleted": "{num} iscritto(i) eliminato(i)",
"templates.cantDeleteDefault": "Impossibile eliminare il modello predefinito",
"templates.default": "Predefinito",
"templates.dummyName": "Campagna di prova",
"templates.dummySubject": "Oggetto della campagna di prova",
"templates.errorCompiling": "Errore durante la compilazione del modello: {error}",
"templates.errorRendering": "Messaggio di errore durante il rendering: {errore}",
"templates.fieldInvalidName": "Lunghezza del nome non valida.",
"templates.makeDefault": "Definisci per impostazione predefinita",
"templates.newTemplate": "Nuovo modello",
"templates.placeholderHelp": "Il segnaposto {placeholder} deve apparire esattamente una volta nel modello.",
"templates.preview": "Anteprima",
"templates.rawHTML": "HTML semplice"
}

View File

@ -2,6 +2,7 @@
"_.code": "ml",
"_.name": "മലയാളം (ml)",
"admin.errorMarshallingConfig": "അഭ്യർത്ഥന ക്രമീകരിയ്ക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
"campaigns.addAltText": "Add alternate plain text message",
"campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.",
"campaigns.clicks": "ക്ലീക്കുകൾ",
"campaigns.confirmDelete": "{name} നീക്കം ചെയ്യുക",
@ -24,6 +25,7 @@
"campaigns.fromAddress": "പ്രേക്ഷകൻ",
"campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>",
"campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.",
"campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ",
"campaigns.noKnownSubsToTest": "ടെസ്റ്റ് ചെയ്യാൻ, വരിക്കാരുടെ പട്ടിക ശൂന്യമാണ്.",
@ -42,6 +44,7 @@
"campaigns.progress": "പുരോഗതി",
"campaigns.queryPlaceholder": "പേരോ വിഷയമോ",
"campaigns.rawHTML": "അസംസ്കൃത എച്. ടി. എം. എൽ",
"campaigns.removeAltText": "Remove alternate plain text message",
"campaigns.richText": "റിച്ച് ടെക്സ്റ്റ്",
"campaigns.schedule": "ക്യാമ്പേയ്ൻ ആസൂത്രണം ചെയ്യുക",
"campaigns.scheduled": "ആസൂത്രണം ചെയ്തു",
@ -77,7 +80,7 @@
"email.optin.confirmSubHelp": "നിങ്ങൾ വരിക്കാരനാകുന്നത് താഴെയുള്ള ബട്ടണിൽ ഞെക്കിക്കൊണ്ട് സ്ഥിരീകരിക്കുക.",
"email.optin.confirmSubInfo": "നിങ്ങൾ താഴെപ്പറയുന്ന ലിസ്റ്റുകളിൽ അംഗമാണ്:",
"email.optin.confirmSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"email.optin.confirmSubWelcome": "നമസ്കാരം {name},",
"email.optin.confirmSubWelcome": "നമസ്കാരം",
"email.optin.privateList": "സ്വകാര്യ ലിസ്റ്റ്",
"email.status.campaignReason": "കാരണം",
"email.status.campaignSent": "അയച്ചു",
@ -90,7 +93,9 @@
"email.unsubHelp": "ഈ-മെയിലുകൾ ഇനി സ്വീകരിക്കേണ്ടതില്ലേ?",
"forms.formHTML": "എച്. ടി. എം. എൽ ഫോം",
"forms.formHTMLHelp": "മറ്റൊരു വെബ് പേജിൽ സബ്സ്ക്രിപ്ഷൻ ഫോം കാണിയ്ക്കുന്നതിന് താഴെക്കൊടുത്തിരിക്കുന്ന എച്. ടി. എം. എൽ ഉപയോഗിക്കുക.",
"forms.noPublicLists": "There are no public lists to generate a forms.",
"forms.publicLists": "പൊതു ലിസ്റ്റുകൾ",
"forms.publicSubPage": "Public subscription page",
"forms.selectHelp": "ഫോമിലേയ്ക്ക് ചേർക്കേണ്ട ലിസ്റ്റുകൾ.",
"forms.title": "ഫോമുകൾ",
"globals.buttons.add": "ചേർക്കുക",
@ -108,13 +113,13 @@
"globals.buttons.remove": "നീക്കം ചെയ്യുക",
"globals.buttons.save": "സൂക്ഷിക്കുക",
"globals.buttons.saveChanges": "മാറ്റങ്ങൾ സൂക്ഷിക്കുക",
"globals.days.0": "ഞായർ",
"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": "പേര്",
@ -242,6 +247,8 @@
"public.errorTitle": "എറർ",
"public.invalidFeature": "ഈ ഫീച്ചർ ലഭ്യമല്ല",
"public.invalidLink": "കണ്ണി അസാധുവാണ്",
"public.noListsAvailable": "No lists available to subscribe.",
"public.noListsSelected": "No valid lists selected to subscribe.",
"public.noSubInfo": "സ്ഥിരീകരിക്കാനായി വരിക്കാരനാകാനുള്ള അഭ്യർത്ഥനകളൊന്നുമില്ല",
"public.noSubTitle": "വരിക്കാരാരുമില്ല",
"public.notFoundTitle": "കണ്ടെത്തിയില്ല",
@ -251,10 +258,14 @@
"public.privacyTitle": "സ്വകാര്യതയും വിവരങ്ങളും",
"public.privacyWipe": "നിങ്ങളുടെ വിവരങ്ങൾ എന്നന്നേയ്ക്കുമായി ഇല്ലാതാക്കുക",
"public.privacyWipeHelp": "താങ്കൾ വരിക്കാരനായിരിക്കുന്നതും അനുബന്ധ വിവരങ്ങളും ഡേറ്റാബേസിൽ നിന്നും എന്നത്തേയ്ക്കുമായി നീക്കം ചെയ്യുക.",
"public.sub": "Subscribe",
"public.subConfirmed": "വരിക്കാരനായി",
"public.subConfirmedTitle": "സ്ഥിരീകരിച്ചു",
"public.subName": "Name (optional)",
"public.subNotFound": "വരിക്കാരനെ കണ്ടത്തിയില്ല.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subPrivateList": "സ്വകാര്യ ലിസ്റ്റ്",
"public.subTitle": "Subscribe",
"public.unsub": "വരിക്കാരനല്ലാതാകുക",
"public.unsubFull": "ഭാവിയിലുള്ള ഇ-മെയിലുകളിൽനിന്നും ഒഴിവാകുക.",
"public.unsubHelp": "ഇനിമേൽ ഈ ലിസ്റ്റിന്റെ വരിക്കാരനാകേണ്ട എന്നുറപ്പാണോ?",
@ -262,11 +273,14 @@
"public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി",
"public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക",
"public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.duplicateMessengerName": "ഒരേ പേരിൽ ഒന്നിലധികം സന്ദശവാഹകർ: {name}",
"settings.errorEncoding": "ക്രമീകരണം എൻകോഡ് ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}",
"settings.errorNoSMTP": "കുറഞ്ഞപക്ഷം ഒരു എസ്. എം. ടീ. പീ ബ്ലൊക്കെങ്കിലും പ്രവർത്തനക്ഷമയിരിക്കണം",
"settings.general.adminNotifEmails": "കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പ് ഇ-മെയിലുകൾ",
"settings.general.adminNotifEmailsHelp": "ഇംപോർട്ട് ചെയ്തതിലുള്ള വിവരങ്ങൾ, ക്യാമ്പേയ്ൻ പൂർത്തീകരണം, പ്രശ്നങ്ങൾ എന്നിങ്ങനെയുള്ള പ്രധാനപ്പെട്ട കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പിനായുള്ള കോമാ ഉപയോഗിച്ച് വേർതിരിച്ച ഇ-മെയിൽ വിലാസങ്ങൾ.",
"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": "ഫാവ് ഐക്കൺ യൂ. ആർ. എൽ",
"settings.general.faviconURLHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ഫാവ് ഐക്കണിന്റെ പൂർണ്ണ വെബ് വിലാസം.",
"settings.general.fromEmail": "സ്ഥിരസ്ഥിതി `from` ഇ-മെയിൽ",
@ -286,7 +300,7 @@
"settings.media.s3.bucketTypePrivate": "സ്വകാര്യമായ",
"settings.media.s3.bucketTypePublic": "പൊതുവായ",
"settings.media.s3.key": "AWS പ്രവേശന വാക്യം",
"settings.media.s3.region": "മേല",
"settings.media.s3.region": "മേല",
"settings.media.s3.secret": "AWS പ്രവേശന രഹസ്യം",
"settings.media.s3.uploadExpiry": "അപ്ലോഡിന്റെ കാലാവധി",
"settings.media.s3.uploadExpiryHelp": "(ഐച്ഛികം) മുൻകൂട്ടി നിർമ്മിക്കുന്ന യൂ. ആർ. എല്ലിനുള്ള സെക്കന്റിലുള്ള TTL വ്യക്തമാക്കുക . സ്വകാര്യ ബക്കറ്റുകൾക്ക് മാത്രമേ ബാധകമാകൂ (s, m, h, d എന്നിവ യഥാക്രമം സെക്കന്റ്, മിനുട്ട്, മണിക്കൂർ, ദിവസങ്ങൾ എന്നിവയെ സൂചിപ്പിക്കുന്നു).",
@ -299,7 +313,7 @@
"settings.messengers.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
"settings.messengers.messageDiscard": "മാറ്റങ്ങൾ നിരസിക്കട്ടെ?",
"settings.messengers.messageSaved": "ക്രമീകരണങ്ങൾ സംരക്ഷിച്ചു. ആപ്പ് പുനരാരംഭിക്കുന്നു ...",
"settings.messengers.name": "സന്േശ വാഹകർ",
"settings.messengers.name": "സന്േശ വാഹകർ",
"settings.messengers.nameHelp": "ഉദാഹരണം: എന്റെ-ലിസ്റ്റ്. അക്കങ്ങളും അക്ഷരങ്ങളും / ഡാഷും.",
"settings.messengers.password": "രഹസ്യ വാക്ക്",
"settings.messengers.retries": "പുനഃശ്രമങ്ങൾ",
@ -310,6 +324,7 @@
"settings.messengers.url": "യൂ. ആർ. എൽ",
"settings.messengers.urlHelp": "പോസ്റ്റ്ബാക്ക് സേർവറിന്റെ റൂട്ട് യൂ. ആർ. എൽ.",
"settings.messengers.username": "ഉപഭോക്ത്ര നാമം",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.performance.batchSize": "ബാച്ചിന്റെ വലിപ്പം",
"settings.performance.batchSizeHelp": "ഒരാവർത്തനത്തിൽ എത്ര വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കണം. ഓരോ തവണയും വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കുകയും അടുത്ത ആവർത്തനത്തിൽ അടുത്ത ബാച്ചിനെ എടുക്കുകയും അങ്ങനെ തുടരുകയും ചെയ്യും. ഈ മൂല്യം പരമാവധി ത്രൂപുട്ടിനേക്കാളും (concurrency * message_rate) കൂടുതലാകുന്നതാണ് നല്ലത്.",
"settings.performance.concurrency": "കൺകറൻസി",
@ -336,6 +351,7 @@
"settings.privacy.listUnsubHeader": "`List-Unsubscribe` തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക",
"settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.",
"settings.privacy.name": "സ്വകാര്യത",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ",
"settings.smtp.customHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ",
"settings.smtp.customHeadersHelp": "ഈ സേർവറിൽ നിന്നും അയക്കുന്ന എല്ലാ ഈ-മെയിലിലും ഉണ്ടാകേണ്ട ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ. ഉദാഹരണം: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
@ -364,6 +380,7 @@
"settings.smtp.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി",
"settings.smtp.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
"settings.title": "ക്രമീകരണങ്ങൾ",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "വിപുലമായത്",
"subscribers.advancedQueryHelp": "വരിക്കാരുടെ വിവരങ്ങൾ മനസിലാക്കുന്നതിനായുള്ള ഭാഗികമായ SQL പ്രയേഗം",
"subscribers.attribs": "ആട്രിബ്യൂട്ടുകൾ",
@ -400,6 +417,7 @@
"subscribers.reset": "പുനഃസജ്ജമാക്കുക",
"subscribers.selectAll": "{num} എല്ലാം തിരഞ്ഞടുക്കുക",
"subscribers.status.blocklisted": "തടയുന്ന പട്ടികയിൽ ചേർത്തു",
"subscribers.status.confirmed": "Confirmed",
"subscribers.status.enabled": "പ്രവർത്തനക്ഷമാക്കി",
"subscribers.status.subscribed": "വരിക്കാരനായി",
"subscribers.status.unconfirmed": "തീർച്ചപ്പെടുത്താത്തത്",

438
i18n/pt-BR.json Normal file
View File

@ -0,0 +1,438 @@
{
"_.code": "pt-BR",
"_.name": "Português Brasileiro (pt-BR)",
"admin.errorMarshallingConfig": "Erro ao ler as configurações: {error}",
"campaigns.addAltText": "Adicionar mensagem alternativa em texto simples",
"campaigns.cantUpdate": "Não é possível atualizar uma campanha em execução ou finalizada.",
"campaigns.clicks": "Cliques",
"campaigns.confirmDelete": "Excluir {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": "Data e hora",
"campaigns.ended": "Finalizada",
"campaigns.errorSendTest": "Erro ao enviar o 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": "Quantidade de caracteres inválida para o nome.",
"campaigns.fieldInvalidSendAt": "A data agendada deve ser no futuro.",
"campaigns.fieldInvalidSubject": "Quantidade de caracteres inválida para o assunto.",
"campaigns.fromAddress": "Endereço do remetente",
"campaigns.fromAddressPlaceholder": "Seu Nome <noreply@yoursite.com>",
"campaigns.invalid": "Campanha inválida",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "A campanha precisa de uma data para ser programada.",
"campaigns.newCampaign": "Nova campanha",
"campaigns.noKnownSubsToTest": "Nenhum assinante conhecido para testar.",
"campaigns.noOptinLists": "Nenhuma lista opt-in encontrada para criar campanha.",
"campaigns.noSubs": "Não há assinantes nas listas selecionadas para criar a campanha.",
"campaigns.noSubsToTest": "Não há nenhum assinantes pra enviar.",
"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 campanhas em rascunho podem ser agendadas.",
"campaigns.onlyPausedDraft": "Apenas campanhas pausadas e em rascunhos podem ser iniciadas.",
"campaigns.onlyScheduledAsDraft": "Apenas campanhas agendadas podem ser salvas como rascunhos.",
"campaigns.pause": "Pausar",
"campaigns.plainText": "Texto simples",
"campaigns.preview": "Pré-visualizar",
"campaigns.progress": "Progresso",
"campaigns.queryPlaceholder": "Nome ou assunto",
"campaigns.rawHTML": "Código HTML",
"campaigns.removeAltText": "Remover mensagem alternativa em texto simples",
"campaigns.richText": "Texto com formatação",
"campaigns.schedule": "Agendar campanha",
"campaigns.scheduled": "Agendada",
"campaigns.send": "Enviar",
"campaigns.sendLater": "Enviar mais tarde",
"campaigns.sendTest": "Enviar mensagem de teste",
"campaigns.sendTestHelp": "Pressione a tecla enter depois de digitar um endereço para adicionar vários destinatários. Os endereços devem pertencer a membros existentes.",
"campaigns.sendToLists": "Listas para enviar para",
"campaigns.sent": "Enviada",
"campaigns.start": "Iniciar campanha",
"campaigns.started": "Campanha \"{name}\" iniciada",
"campaigns.startedAt": "Iniciada",
"campaigns.stats": "Estatísticas",
"campaigns.status.cancelled": "Cancelada",
"campaigns.status.draft": "Rascunho",
"campaigns.status.finished": "Finalizada",
"campaigns.status.paused": "Pausada",
"campaigns.status.running": "Executando",
"campaigns.status.scheduled": "Agendado",
"campaigns.statusChanged": "O status da campanha \"{name}\" é {status}",
"campaigns.subject": "Assunto",
"campaigns.testEmails": "E-mails",
"campaigns.testSent": "Mensagem de teste enviada",
"campaigns.timestamps": "Data e hora",
"campaigns.views": "Visualizações",
"dashboard.campaignViews": "Visualizações da campanha",
"dashboard.linkClicks": "Links clicados",
"dashboard.messagesSent": "Mensagens enviadas",
"dashboard.orphanSubs": "Órfãos",
"email.data.info": "Uma cópia de todos os dados associados a você está anexado em um arquivo JSON. Ele pode ser ler o conteúdo em um editor de texto.",
"email.data.title": "Seus dados",
"email.optin.confirmSub": "Confirmar a assinatura",
"email.optin.confirmSubHelp": "Confirme sua assinatura clicando no botão abaixo.",
"email.optin.confirmSubInfo": "Você foi adicionado às seguintes listas:",
"email.optin.confirmSubTitle": "Confirmar a assinatura",
"email.optin.confirmSubWelcome": "Olá",
"email.optin.privateList": "Lista privada",
"email.status.campaignReason": "Motivo",
"email.status.campaignSent": "Enviada",
"email.status.campaignUpdateTitle": "Atualizar a campanha",
"email.status.importFile": "Arquivo",
"email.status.importRecords": "Registros",
"email.status.importTitle": "Importar atualização",
"email.status.status": "Status",
"email.unsub": "Cancelar assinatura",
"email.unsubHelp": "Não quer mais receber estes e-mails?",
"forms.formHTML": "Formulário HTML",
"forms.formHTMLHelp": "Use este HTML para inserir um formulário de inscrição em uma página externa. O formulário deve ter o campo de e-mail e um ou mais campos `l` (lista UUID). O campo nome é opcional.",
"forms.noPublicLists": "Não há nenhuma lista pública para gerar um formulário.",
"forms.publicLists": "Listas públicas",
"forms.publicSubPage": "Página pública de assinatura",
"forms.selectHelp": "Selecione 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": "Clonar",
"globals.buttons.close": "Fechar",
"globals.buttons.continue": "Continuar",
"globals.buttons.delete": "Excluir",
"globals.buttons.edit": "Editar",
"globals.buttons.enabled": "Habilitado",
"globals.buttons.learnMore": "Saiba mais",
"globals.buttons.new": "Novo",
"globals.buttons.ok": "Ok",
"globals.buttons.remove": "Excluir",
"globals.buttons.save": "Salvar",
"globals.buttons.saveChanges": "Salvar alterações",
"globals.days.0": "Dom",
"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.fields.createdAt": "Criado",
"globals.fields.id": "ID",
"globals.fields.name": "Nome",
"globals.fields.status": "Status",
"globals.fields.type": "Tipo",
"globals.fields.updatedAt": "Atualizado",
"globals.fields.uuid": "UUID",
"globals.messages.confirm": "Tem certeza?",
"globals.messages.created": "\"{name}\" criado",
"globals.messages.deleted": "\"{name}\" excluído",
"globals.messages.emptyState": "Nada por aqui",
"globals.messages.errorCreating": "Erro ao criar {name}: {error}",
"globals.messages.errorDeleting": "Erro ao excluir {name}: {error}",
"globals.messages.errorFetching": "Erro ao obter {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": "Digite 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": "Campanhas",
"globals.terms.dashboard": "Painel",
"globals.terms.list": "Lista | Listas",
"globals.terms.lists": "Listas",
"globals.terms.media": "Mídia | Mídias",
"globals.terms.messenger": "Mensageiro | Mensageiros",
"globals.terms.messengers": "Mensageiros",
"globals.terms.settings": "Configurações",
"globals.terms.subscriber": "Assinante | Assinantes",
"globals.terms.subscribers": "Assinantes",
"globals.terms.tag": "Tag | Tags",
"globals.terms.tags": "Tags",
"globals.terms.template": "Modelo | Modelos",
"globals.terms.templates": "Modelos",
"import.alreadyRunning": "Uma importação já está em execução. Aguarde até que termine ou pare-a antes de tentar novamente.",
"import.blocklist": "Lista de bloqueio",
"import.csvDelim": "Delimitador CSV",
"import.csvDelimHelp": "Delimitador padrão é vírgula.",
"import.csvExample": "Exemplo de CSV bruto",
"import.csvFile": "Arquivo CSV ou ZIP",
"import.csvFileHelp": "Clique ou arraste um arquivo CSV ou ZIP aqui",
"import.errorCopyingFile": "Erro ao copiar arquivo: {error}",
"import.errorProcessingZIP": "Erro ao processar o arquivo ZIP: {error}",
"import.errorStarting": "Erro ao iniciar importação: {error}",
"import.importDone": "Finalizada",
"import.importStarted": "Importação iniciada",
"import.instructions": "Instruções",
"import.instructionsHelp": "Envie um arquivo CSV ou um arquivo ZIP contendo um único arquivo CSV para a importação de assinantes lote. O arquivo CSV deve ter os seguintes cabeçalhos com os nomes exatos das colunas. Os atributos (opcional) devem ser uma string JSON válida com aspas duplas.",
"import.invalidDelim": "O delimitador deve ser um único caractere.",
"import.invalidFile": "Arquivo inválido: {error}",
"import.invalidMode": "Modo inválido",
"import.invalidParams": "Parâmetros inválidos: {error}",
"import.listSubHelp": "Listas para inscrever.",
"import.mode": "Modo",
"import.overwrite": "Sobrescrever?",
"import.overwriteHelp": "Sobrescrever nome e atributos de inscritos existentes?",
"import.recordsCount": "{num} / {total} registros",
"import.stopImport": "Parar importação",
"import.subscribe": "Inscrever",
"import.title": "Importar inscritos",
"import.upload": "Enviar arquivo",
"lists.confirmDelete": "Você tem certeza? Isso não exclui inscritos.",
"lists.confirmSub": "Confirmar assinatura(s) para {name}",
"lists.invalidName": "Nome inválido",
"lists.newList": "Nova lista",
"lists.optin": "Confirmação da inscrição",
"lists.optinHelp": "A inscrição com confirmação envia um e-mail para o inscrito pedindo que ele confirme a inscrição. Nas listas com inscrição com confirmação, as campanhas são enviadas apenas para inscritos que confirmaram a inscrição.",
"lists.optinTo": "Inscrição com confirmação para {name}",
"lists.optins.double": "Inscrição com confirmação",
"lists.optins.single": "Inscrição simples",
"lists.sendCampaign": "Enviar campanha",
"lists.sendOptinCampaign": "Enviada campanha de confirmação de inscrição",
"lists.type": "Tipo",
"lists.typeHelp": "Listas públicas estão abertas ao mundo para se inscrever e seus nomes podem aparecer em páginas públicas, como na página de gerenciamento de inscrições.",
"lists.types.private": "Privada",
"lists.types.public": "Pública",
"logs.title": "Logs",
"media.errorReadingFile": "Erro ao ler arquivo: {error}",
"media.errorResizing": "Erro ao redimensionar imagem: {error}",
"media.errorSavingThumbnail": "Erro ao salvar miniatura: {error}",
"media.errorUploading": "Erro ao enviar o arquivo: {error}",
"media.invalidFile": "Arquivo inválido: {error}",
"media.title": "Mídia",
"media.unsupportedFileType": "Tipo de arquivo não suportado ({type})",
"media.upload": "Enviar arquivo",
"media.uploadHelp": "Clique ou arraste uma ou mais imagens aqui",
"media.uploadImage": "Enviar Imagem",
"menu.allCampaigns": "Todas as campanhas",
"menu.allLists": "Todas as listas",
"menu.allSubscribers": "Todos os inscritos",
"menu.dashboard": "Painel",
"menu.forms": "Formulários",
"menu.import": "Importação",
"menu.logs": "Logs",
"menu.media": "Mídia",
"menu.newCampaign": "Criar nova",
"menu.settings": "Configurações",
"public.campaignNotFound": "A mensagem do e-mail não foi encontrada.",
"public.confirmOptinSubTitle": "Confirmar a assinatura",
"public.confirmSub": "Confirmar a assinatura",
"public.confirmSubInfo": "Você foi adicionado às seguintes listas:",
"public.confirmSubTitle": "Confirmar",
"public.dataRemoved": "Suas assinaturas e todos os dados associados foram removidos.",
"public.dataRemovedTitle": "Dados removidos",
"public.dataSent": "Seus dados foram enviados em anexo para seu e-mail.",
"public.dataSentTitle": "Dados enviados para seu e-mail",
"public.errorFetchingCampaign": "Erro ao obter a mensagem do e-mail.",
"public.errorFetchingEmail": "Mensagem do e-mail não encontrada",
"public.errorFetchingLists": "Erro ao obter as listas. Por favor, tente novamente.",
"public.errorProcessingRequest": "Erro ao processar a solicitação. Por favor, tente novamente.",
"public.errorTitle": "Erro",
"public.invalidFeature": "Este recurso não está disponível.",
"public.invalidLink": "Link inválido",
"public.noListsAvailable": "Não há listas disponíveis para se inscrever.",
"public.noListsSelected": "Não foram selecionadas listas válidas para inscrever.",
"public.noSubInfo": "Não há nenhuma inscrição para confirmar.",
"public.noSubTitle": "Sem inscrições",
"public.notFoundTitle": "Não Encontrado",
"public.privacyConfirmWipe": "Você tem certeza que deseja excluir todos os seus dados de assinatura permanentemente?",
"public.privacyExport": "Exportar seus dados",
"public.privacyExportHelp": "Uma cópia de seus dados será enviado por e-mail para você.",
"public.privacyTitle": "Privacidade e dados",
"public.privacyWipe": "Limpe seus dados",
"public.privacyWipeHelp": "Excluir todas as suas assinaturas e dados relacionados do banco de dados permanentemente.",
"public.sub": "Inscrever-se",
"public.subConfirmed": "Inscrito com sucesso.",
"public.subConfirmedTitle": "Confirmado",
"public.subName": "Nome (opcional)",
"public.subNotFound": "Inscrição não encontrada.",
"public.subOptinPending": "Um e-mail foi enviado a você para confirmar sua(s) inscrição(ões).",
"public.subPrivateList": "Lista privada",
"public.subTitle": "Cancelar a inscrição",
"public.unsub": "Cancelar a inscrição",
"public.unsubFull": "Também cancelar a inscrição de todos os e-mails futuros.",
"public.unsubHelp": "Deseja cancelar a inscrição desta lista de e-mail?",
"public.unsubTitle": "Cancelar inscrição",
"public.unsubbedInfo": "Você cancelou a inscrição com sucesso.",
"public.unsubbedTitle": "Inscrição cancelada",
"public.unsubscribeTitle": "Cancelar inscrição na lista de e-mails",
"settings.confirmRestart": "Certifique-se de que as campanhas em execução estão pausadas. Reiniciar?",
"settings.duplicateMessengerName": "Nome duplicado do mensageiro: {name}",
"settings.errorEncoding": "Erro ao codificar as configurações: {error}",
"settings.errorNoSMTP": "Pelo menos um bloco SMTP deve estar habilitado",
"settings.general.adminNotifEmails": "E-mails de notificação de administrador",
"settings.general.adminNotifEmailsHelp": "Lista de e-mails separados por vírgula para os quais as notificações de administração, como atualizações de importação, conclusão da campanha, falha, etc. devem ser enviadas.",
"settings.general.enablePublicSubPage": "Habilitar a página pública de inscrição",
"settings.general.enablePublicSubPageHelp": "Habilitar a página pública de inscrição com todas as listas públicas para as pessoas se inscreverem.",
"settings.general.faviconURL": "URL do Favicon",
"settings.general.faviconURLHelp": "(Opcional) URL completo do favicon estático para ser visualizado pelo usuário, como a página de cancelamento de inscrição.",
"settings.general.fromEmail": "E-mail `de` padrão",
"settings.general.fromEmailHelp": "E-mail `de` padrão é usada nas mensagens de e-mails enviadas. Isso pode ser alterado por campanha.",
"settings.general.language": "Idioma",
"settings.general.logoURL": "URL do logotipo",
"settings.general.logoURLHelp": "(Opcional) URL completo do logotipo estático para ser visualizado pelo usuário, como a página de cancelamento de inscriçã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": "Provedor",
"settings.media.s3.bucket": "Bucket",
"settings.media.s3.bucketPath": "Caminho do bucket",
"settings.media.s3.bucketPathHelp": "Caminho dentro do bucket para enviar os arquivos. O 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": "Expiração do arquivo enviado",
"settings.media.s3.uploadExpiryHelp": "(Opcional) Especificar TTL (em segundos) para a URL pré-assinada gerada. Apenas aplicável para buckets privados (s, m, h, d para segundos, minutos, horas e dias).",
"settings.media.title": "Envios de mídias",
"settings.media.upload.path": "Caminho de envio",
"settings.media.upload.pathHelp": "Caminho para o diretório onde a mídia será enviado.",
"settings.media.upload.uri": "URI de envio",
"settings.media.upload.uriHelp": "URI de envio que é visível ao mundo exterior. Todas as mídias enviadas para o upload_path será publicamente acessível em {root_url}, por exemplo, https://listmonk.exemplo.com.br/uploads.",
"settings.messengers.maxConns": "Máx. conexões",
"settings.messengers.maxConnsHelp": "Máximo de conexões simultâneas para o servidor.",
"settings.messengers.messageDiscard": "Descartar alterações?",
"settings.messengers.messageSaved": "Configurações salvas. Recarregando o aplicativo...",
"settings.messengers.name": "Mensageiros",
"settings.messengers.nameHelp": "ex: meu-sms. Alfanuméricos / traço.",
"settings.messengers.password": "Senha",
"settings.messengers.retries": "Tentativas",
"settings.messengers.retriesHelp": "Número de tentativas quando uma mensagem falhar.",
"settings.messengers.skipTLSHelp": "Pular verificação de hostname sobre o certificado TLS.",
"settings.messengers.timeout": "Tempo de espera limite",
"settings.messengers.timeoutHelp": "Tempo para esperar por uma nova atividade em uma conexão antes de fechá-la e removê-la do pool (s parar segundo, m para minuto).",
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "URL base do servidor Postback.",
"settings.messengers.username": "Usuário",
"settings.needsRestart": "Configurações alteradas. Pause todas as campanhas em execução e reiniciar o aplicativo",
"settings.performance.batchSize": "Tamanho do lote",
"settings.performance.batchSizeHelp": "O número de inscritos para puxar do banco de dados em uma única iteração. Cada iteração puxa assinantes da base de dados, envia mensagens para eles, e então passa para a próxima iteração para puxar o próximo lote. O ideal é que isso seja mais alto do que o máximo possível de transferência (concorrência * taxa de mensagem).",
"settings.performance.concurrency": "Concorrência",
"settings.performance.concurrencyHelp": "Máximo de trabalhador simultâneo (threads) que tentará enviar mensagens simultaneamente.",
"settings.performance.maxErrThreshold": "Limite máximo de erros",
"settings.performance.maxErrThresholdHelp": "O número de erros (por exemplo: tempo limite SMTP ao enviar e-mail) uma campanha em curso deve tolerar antes de ser pausada para investigação manual ou intervenção. Marque 0 para nunca pausar.",
"settings.performance.messageRate": "Taxa de mensagens",
"settings.performance.messageRateHelp": "Número máximo de mensagens a serem enviadas por segundo por trabalhador em um segundo. Se a concorrência = 10 e taxa de mensagem = 10, então até 10x10=100 mensagens podem ser enviadas a cada segundo. Isto, juntamente com a concorrência, deve ser ajustado para manter as mensagens saindo da rede por segundo abaixo dos limites de taxa dos servidores de mensagens de destino, se houver.",
"settings.performance.name": "Performance",
"settings.performance.slidingWindow": "Habilitar limite da janela deslizante",
"settings.performance.slidingWindowDuration": "Duração",
"settings.performance.slidingWindowDurationHelp": "Duração do período da janela deslizante (m para minuto, h para hora).",
"settings.performance.slidingWindowHelp": "Limitar o número total de mensagens enviadas em determinado período. Ao atingir 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 a serem enviadas dentro da duração da janela.",
"settings.privacy.allowBlocklist": "Permitir lista de bloqueio",
"settings.privacy.allowBlocklistHelp": "Permitir que os inscritos cancelem a inscrição de todas as listas de e-mails e se marquem como bloqueados?",
"settings.privacy.allowExport": "Permitir exportação",
"settings.privacy.allowExportHelp": "Permitir que os assinantes exportem os dados coletados neles?",
"settings.privacy.allowWipe": "Permitir limpeza",
"settings.privacy.allowWipeHelp": "Permitir que os assinantes se excluam incluindo suas inscrições e todos os outros dados da base de dados. Visualizações da campanha e cliques de links também são removidos enquanto o total de visualizações e cliques permanecem (com nenhum inscrito associado a eles) para que as estatísticas e análises não sejam afetadas.",
"settings.privacy.individualSubTracking": "Rastreamento individual de inscrito",
"settings.privacy.individualSubTrackingHelp": "Rastrear visualizações e cliques de cada inscrito. Quando desativado, o rastreio da visualizações e clique continuar sem estar associado a nenhuma inscrição.",
"settings.privacy.listUnsubHeader": "Incluir cabeçalho `List-Unsubscribe`",
"settings.privacy.listUnsubHeaderHelp": "Incluir cabeçalhos de desinscrição que permitem aos clientes de e-mail cancelem a inscrição em um único clique.",
"settings.privacy.name": "Privacidade",
"settings.restart": "Reiniciar",
"settings.smtp.authProtocol": "Protocolo Autenticação",
"settings.smtp.customHeaders": "Cabeçalhos personalizados",
"settings.smtp.customHeadersHelp": "Array opcional de cabeçalhos de e-mail para incluir em todas as mensagens enviadas a partir deste servidor. por exemplo: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Habilitado",
"settings.smtp.heloHost": "Nome do host HELO",
"settings.smtp.heloHostHelp": "Opcional. Alguns servidores SMTP exigem um FQDN no nome do host. Por padrão, os HELLOs vão com 'localhost'. Defina isto se um nome de host personalizado deve ser usado.",
"settings.smtp.host": "Host",
"settings.smtp.hostHelp": "Endereço do servidor SMTP.",
"settings.smtp.idleTimeout": "Tempo limite ocioso",
"settings.smtp.idleTimeoutHelp": "Tempo para esperar por uma nova atividade em uma conexão antes de fechá-la e removê-la do pool (s parar segundo, m para minuto).",
"settings.smtp.maxConns": "Máx. Conexões",
"settings.smtp.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor SMTP.",
"settings.smtp.name": "SMTP",
"settings.smtp.password": "Senha",
"settings.smtp.passwordHelp": "Digite para alterar",
"settings.smtp.port": "Porta",
"settings.smtp.portHelp": "Porta do servidor SMTP.",
"settings.smtp.retries": "Tentativas",
"settings.smtp.retriesHelp": "Número de tentativas quando uma mensagem falhar.",
"settings.smtp.setCustomHeaders": "Definir cabeçalhos personalizados",
"settings.smtp.skipTLS": "Pular verificação de TLS",
"settings.smtp.skipTLSHelp": "Pular verificação de hostname sobre o certificado TLS.",
"settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Habilitar STARTTLS.",
"settings.smtp.username": "Usuário",
"settings.smtp.waitTimeout": "Tempo limite de espera",
"settings.smtp.waitTimeoutHelp": "Tempo para esperar por uma nova atividade em uma conexão antes de fechá-la e removê-la do pool (s parar segundo, m para minuto).",
"settings.title": "Configurações",
"settings.updateAvailable": "Atualização: a nova versão {version} já está disponível.",
"subscribers.advancedQuery": "Avançado",
"subscribers.advancedQueryHelp": "Expressão de SQL parcial para consultar atributos dos inscritos",
"subscribers.attribs": "Atributos",
"subscribers.attribsHelp": "Atributos são definidos como um mapa JSON, por exemplo:",
"subscribers.blocklistedHelp": "Inscritos bloqueados nunca receberão quaisquer e-mails.",
"subscribers.confirmBlocklist": "Bloquear {num} inscrito(s)?",
"subscribers.confirmDelete": "Excluir {num} inscrito(s)?",
"subscribers.confirmExport": "Exportar {num} inscrito(s)?",
"subscribers.downloadData": "Baixar dados",
"subscribers.email": "E-mail",
"subscribers.emailExists": "E-mail já existe.",
"subscribers.errorBlocklisting": "Erro ao bloquear inscritos: {error}",
"subscribers.errorInvalidIDs": "Um ou mais IDs inválidos: {error}",
"subscribers.errorNoIDs": "Nenhum ID informado.",
"subscribers.errorNoListsGiven": "Nenhuma lista informada.",
"subscribers.errorPreparingQuery": "Erro ao preparar consulta de inscritos: {error}",
"subscribers.errorSendingOptin": "Erro ao enviar e-mail de confirmação de inscrição.",
"subscribers.export": "Exportar",
"subscribers.invalidAction": "Ação inválida.",
"subscribers.invalidEmail": "E-mail inválido.",
"subscribers.invalidJSON": "JSON inválido nos atributos.",
"subscribers.invalidName": "Nome inválido.",
"subscribers.listChangeApplied": "Alterações na lista aplicadas.",
"subscribers.lists": "Listas",
"subscribers.listsHelp": "Listas das quais os inscritos cancelaram a inscrição por eles mesmos não podem ser removidos.",
"subscribers.listsPlaceholder": "Listas para inscrever",
"subscribers.manageLists": "Gerenciar listas",
"subscribers.markUnsubscribed": "Marcar como inscrição cancelada",
"subscribers.newSubscriber": "Novo inscrito",
"subscribers.numSelected": "{num} inscrito(s) selecionado(s)",
"subscribers.optinSubject": "Confirmar a inscrição",
"subscribers.query": "Consulta",
"subscribers.queryPlaceholder": "E-mail ou nome",
"subscribers.reset": "Redefinir",
"subscribers.selectAll": "Selecionar todos {num}",
"subscribers.status.blocklisted": "Lista de bloqueados",
"subscribers.status.confirmed": "Confirmado",
"subscribers.status.enabled": "Habilitado",
"subscribers.status.subscribed": "Inscrito",
"subscribers.status.unconfirmed": "Não confirmado",
"subscribers.status.unsubscribed": "Inscrição cancelada",
"subscribers.subscribersDeleted": "{num} inscrito(s) excluído(s)",
"templates.cantDeleteDefault": "Não é possível excluir o modelo padrão",
"templates.default": "Padrão",
"templates.dummyName": "Campanha fictícia",
"templates.dummySubject": "Assunto da campanha fictícia",
"templates.errorCompiling": "Erro ao compilar modelo: {error}",
"templates.errorRendering": "Erro ao renderizar mensagem: {error}",
"templates.fieldInvalidName": "Comprimento inválido para o nome.",
"templates.makeDefault": "Definir como padrão",
"templates.newTemplate": "Novo modelo",
"templates.placeholderHelp": "O palavra reservada {placeholder} deve aparecer exatamente uma vez no modelo.",
"templates.preview": "Pré-visualizar",
"templates.rawHTML": "Código HTML"
}

View File

@ -25,6 +25,7 @@
"campaigns.fromAddress": "Endereço do Remetente",
"campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
"campaigns.invalid": "Campanha inválida",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
"campaigns.newCampaign": "Nova campanha",
"campaigns.noKnownSubsToTest": "Não existem subscritores para testar.",
@ -79,7 +80,7 @@
"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.confirmSubWelcome": "Olá",
"email.optin.privateList": "Lista privada",
"email.status.campaignReason": "Motivo",
"email.status.campaignSent": "Enviada",
@ -92,6 +93,7 @@
"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.noPublicLists": "There are no public lists to generate a forms.",
"forms.publicLists": "Listas públicas",
"forms.publicSubPage": "Página pública de subscrição",
"forms.selectHelp": "Seleciona listas para adicionar ao formulário.",
@ -111,13 +113,13 @@
"globals.buttons.remove": "Remover",
"globals.buttons.save": "Guardar",
"globals.buttons.saveChanges": "Guardar alterações",
"globals.days.0": "Dom",
"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",
@ -261,6 +263,7 @@
"public.subConfirmedTitle": "Confirmado",
"public.subName": "Nome (opcional)",
"public.subNotFound": "Subscrição não encontrada.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subPrivateList": "Lista privada",
"public.subTitle": "Subscrever",
"public.unsub": "Cancelar subscrição",
@ -270,6 +273,7 @@
"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.confirmRestart": "Ensure running campaigns are paused. Restart?",
"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",
@ -320,6 +324,7 @@
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "URL base do servidor Postback.",
"settings.messengers.username": "Nome de utilizador",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"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",
@ -346,6 +351,7 @@
"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.restart": "Restart",
"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\"}]",
@ -374,6 +380,7 @@
"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",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Avançado",
"subscribers.advancedQueryHelp": "Expressão SQL parcial para consultar atributos de subscritores",
"subscribers.attribs": "Atributos",

40
install-demo.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/sh
set -e
# Listmonk demo setup using `docker-compose`.
#
# See https://listmonk.app/docs/installation/ for detailed installation steps.
#
check_dependency() {
if ! command -v curl > /dev/null; then
echo "curl is not installed."
exit 1
fi
if ! command -v docker > /dev/null; then
echo "docker is not installed."
exit 1
fi
if ! command -v docker-compose > /dev/null; then
echo "docker-compose is not installed."
exit 1
fi
}
setup_containers() {
curl -o docker-compose.yml https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml
docker-compose up -d demo-db demo-app
}
show_output(){
echo -e "\nListmonk is now up and running. Visit http://localhost:9000 in your browser.\n"
}
check_dependency
setup_containers
show_output

View File

@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/Masterminds/sprig/v3"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/models"
@ -315,7 +316,7 @@ func (m *Manager) messageWorker() {
// TemplateFuncs returns the template functions to be applied into
// compiled campaign templates.
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
return template.FuncMap{
f := template.FuncMap{
"TrackLink": func(url string, msg *CampaignMessage) string {
subUUID := msg.Subscriber.UUID
if !m.cfg.IndividualTracking {
@ -353,7 +354,14 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
"L": func() *i18n.I18n {
return m.i18n
},
"Safe": func(safeHTML string) template.HTML {
return template.HTML(safeHTML)
},
}
for k, v := range sprig.GenericFuncMap() {
f[k] = v
}
return f
}
// Close closes and exits the campaign manager.
@ -367,6 +375,8 @@ func (m *Manager) Close() {
// for campaigns to process and dispatches them to the manager.
func (m *Manager) scanCampaigns(tick time.Duration) {
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
// Periodically scan the data source for campaigns to process.

View File

@ -0,0 +1,13 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/stuffbin"
)
// V1_0_0 performs the DB migrations for v.1.0.0.
func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`)
return err
}

View File

@ -1,6 +1,7 @@
package models
import (
"bytes"
"database/sql/driver"
"encoding/json"
"errors"
@ -12,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/lib/pq"
"github.com/yuin/goldmark"
null "gopkg.in/volatiletech/null.v6"
)
@ -39,6 +41,7 @@ const (
CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html"
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain"
// List.
@ -312,8 +315,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
return fmt.Errorf("error compiling base template: %v", err)
}
// Compile the campaign message.
// If the format is markdown, convert Markdown to HTML.
if c.ContentType == CampaignContentTypeMarkdown {
var b bytes.Buffer
if err := goldmark.Convert([]byte(c.Body), &b); err != nil {
return err
}
body = b.String()
} else {
body = c.Body
}
// Compile the campaign message.
for _, r := range regTplFuncs {
body = r.regExp.ReplaceAllString(body, r.replace)
}

View File

@ -1,8 +1,13 @@
-- subscribers
-- name: get-subscriber
-- Get a single subscriber by id or UUID.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- Get a single subscriber by id or UUID or email.
SELECT * FROM subscribers WHERE
CASE
WHEN $1 > 0 THEN id = $1
WHEN $2 != '' THEN uuid = $2::UUID
WHEN $3 != '' THEN email = $3
END;
-- name: subscriber-exists
-- Check if a subscriber exists by id or UUID.
@ -115,7 +120,7 @@ WITH s AS (
email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
status=(CASE WHEN $4 != '' THEN $4::subscriber_status ELSE status END),
attribs=(CASE WHEN $5::TEXT != '' THEN $5::JSONB ELSE attribs END),
attribs=(CASE WHEN $5 != '' THEN $5::JSONB ELSE attribs END),
updated_at=NOW()
WHERE id = $1 RETURNING id
),

View File

@ -4,7 +4,7 @@ DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
-- subscribers
DROP TABLE IF EXISTS subscribers CASCADE;

14
scripts/refresh-i18n.sh Executable file
View File

@ -0,0 +1,14 @@
#!/bin/bash
# "Refresh" all i18n language files by merging missing keys in lang files
# from a base language file. In addition, sort all files by keys.
BASE_DIR=$(dirname "$0")"/../i18n" # Exclude the trailing slash.
BASE_FILE="en.json"
# Iterate through all i18n files and merge them into the base file,
# filling in missing keys.
for fpath in "$BASE_DIR/"*.json; do
echo $(basename -- $fpath)
echo "$( jq -s '.[0] * .[1]' -S --indent 4 "$BASE_DIR/$BASE_FILE" $fpath )" > $fpath
done

View File

@ -3,7 +3,7 @@
<h2>{{ L.Ts "email.status.campaignUpdateTitle" }}</h2>
<table width="100%">
<tr>
<td width="30%"><strong>{{ L.Ts "globa.L.Terms.campaign" }}</strong></td>
<td width="30%"><strong>{{ L.Ts "global.terms.campaign" }}</strong></td>
<td><a href="{{ index . "RootURL" }}/campaigns/{{ index . "ID" }}">{{ index . "Name" }}</a></td>
</tr>
<tr>

View File

@ -1,6 +1,6 @@
{{ define "optin-campaign" }}
<p>{{ L.Ts "email.optin.confirmSubWelcome" "name" .Subscriber.FirstName }}</p>
<p>{{ L.Ts "email.optin.confirmSubWelcome" }} {{ "{{" }}.Subscriber.FirstName {{ "}}" }}</p>
<p>{{ L.Ts "email.optin.confirmSubInfo" }}</p>
<ul>
{{ range $i, $l := .Lists }}

View File

@ -1,7 +1,7 @@
{{ define "subscriber-optin" }}
{{ template "header" . }}
<h2>{{ L.Ts "email.optin.confirmSubTitle" }}</h2>
<p>{{ L.Ts "email.optin.confirmSubWelcome" "name" .Subscriber.FirstName }}</p>
<p>{{ L.Ts "email.optin.confirmSubWelcome" }} {{ .Subscriber.FirstName }}</p>
<p>{{ L.Ts "email.optin.confirmSubInfo" }}</p>
<ul>
{{ range $i, $l := .Lists }}