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 goos: linux
goarch: amd64 goarch: amd64
binaries: ids:
- listmonk - listmonk
image_templates: image_templates:
- "listmonk/listmonk:latest" - "listmonk/listmonk:latest"

View File

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

View File

@ -20,7 +20,7 @@ deps:
# Build the backend to ./listmonk. # Build the backend to ./listmonk.
.PHONY: build .PHONY: build
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. # Run the backend.
.PHONY: run .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: 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 #### 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. 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 package main
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sort" "sort"
@ -13,41 +11,29 @@ import (
"github.com/labstack/echo" "github.com/labstack/echo"
) )
type configScript struct { type serverConfig struct {
RootURL string `json:"rootURL"` Messengers []string `json:"messengers"`
FromEmail string `json:"fromEmail"` Langs []i18nLang `json:"langs"`
Messengers []string `json:"messengers"` Lang string `json:"lang"`
MediaProvider string `json:"mediaProvider"` Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needsRestart"` NeedsRestart bool `json:"needs_restart"`
Update *AppUpdate `json:"update"`
Langs []i18nLang `json:"langs"`
EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"`
Lang json.RawMessage `json:"lang"`
} }
// handleGetConfigScript returns general configuration as a Javascript // handleGetServerConfig returns general server config.
// variable that can be included in an HTML page directly. func handleGetServerConfig(c echo.Context) error {
func handleGetConfigScript(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
out = configScript{ out = serverConfig{}
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
MediaProvider: app.constants.MediaProvider,
EnablePublicSubPage: app.constants.EnablePublicSubPage,
}
) )
// Language list. // Language list.
langList, err := geti18nLangList(app.constants.Lang, app) langList, err := getI18nLangList(app.constants.Lang, app)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error loading language list: %v", err)) fmt.Sprintf("Error loading language list: %v", err))
} }
out.Langs = langList out.Langs = langList
out.Lang = app.constants.Lang
// Current language.
out.Lang = json.RawMessage(app.i18n.JSON())
// Sort messenger names with `email` always as the first item. // Sort messenger names with `email` always as the first item.
var names []string var names []string
@ -66,17 +52,7 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update out.Update = app.update
app.Unlock() app.Unlock()
// Write the Javascript variable opening; return c.JSON(http.StatusOK, okResp{out})
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())
} }
// handleGetDashboardCharts returns chart data points to render ont he dashboard. // handleGetDashboardCharts returns chart data points to render ont he dashboard.

View File

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

View File

@ -2,8 +2,6 @@ package main
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@ -39,13 +37,21 @@ var (
) )
// registerHandlers registers HTTP handlers. // registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) { func registerHTTPHandlers(e *echo.Echo, app *App) {
// Group of private handlers with BasicAuth. // 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("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck) g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript) g.GET("/api/config", handleGetServerConfig)
g.GET("/api/lang/:lang", handleLoadLanguage) g.GET("/api/lang/:lang", handleGetI18nLang)
g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts) g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -164,23 +170,6 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
} }
// handleLoadLanguage returns the JSON language pack given the language code.
func handleLoadLanguage(c echo.Context) error {
app := c.Get("app").(*App)
lang := c.Param("lang")
if len(lang) > 6 || reLangCode.MatchString(lang) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
}
b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
return c.JSON(http.StatusOK, okResp{json.RawMessage(b)})
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers. // basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) { func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App) app := c.Get("app").(*App)

View File

@ -3,6 +3,11 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
) )
type i18nLang struct { type i18nLang struct {
@ -15,8 +20,25 @@ type i18nLangRaw struct {
Name string `json:"_.name"` Name string `json:"_.name"`
} }
// geti18nLangList returns the list of available i18n languages. // handleGetI18nLang returns the JSON language pack given the language code.
func geti18nLangList(lang string, app *App) ([]i18nLang, error) { 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") list, err := app.fs.Glob("/i18n/*.json")
if err != nil { if err != nil {
return nil, err return nil, err
@ -42,3 +64,30 @@ func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
return out, nil 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 // 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. // missing translations in it, the default English translations show up.
func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
const def = "en" i, err := getI18nLang(lang, fs)
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
if err != nil { 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 return i
} }
@ -505,7 +487,7 @@ func initHTTPServer(app *App) *echo.Echo {
} }
// Register all HTTP handlers. // Register all HTTP handlers.
registerHTTPHandlers(srv) registerHTTPHandlers(srv, app)
// Start the server. // Start the server.
go func() { go func() {

View File

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

View File

@ -42,6 +42,13 @@ type subsWrap struct {
Page int `json:"page"` 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 // subProfileData represents a subscriber's collated data in JSON
// for export. // for export.
type subProfileData struct { type subProfileData struct {
@ -79,7 +86,11 @@ func handleGetSubscriber(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id")) 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 { if err != nil {
return err return err
} }
@ -273,14 +284,13 @@ func handleCreateSubscriber(c echo.Context) error {
} }
// Insert the subscriber into the DB. // Insert the subscriber into the DB.
sub, err := insertSubscriber(req, app) sub, isNew, _, err := insertSubscriber(req, app)
if err != nil { if err != nil {
if err == errSubscriberExists {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return err return err
} }
if !isNew {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return c.JSON(http.StatusOK, okResp{sub}) return c.JSON(http.StatusOK, okResp{sub})
} }
@ -290,7 +300,7 @@ func handleUpdateSubscriber(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64) id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
req subimporter.SubReq req subUpdateReq
) )
// Get and validate fields. // Get and validate fields.
if err := c.Bind(&req); err != nil { 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")) 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.ToLower(strings.TrimSpace(req.Email)),
strings.TrimSpace(req.Name), strings.TrimSpace(req.Name),
req.Status, req.Status,
req.Attribs, req.RawAttribs,
req.Lists) req.Lists)
if err != nil { if err != nil {
app.log.Printf("error updating subscriber: %v", err) 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). // 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 { if err != nil {
return err return err
} }
_ = sendOptinConfirmation(sub, []int64(req.Lists), app) _, _ = sendOptinConfirmation(sub, []int64(req.Lists), app)
return c.JSON(http.StatusOK, okResp{sub}) return c.JSON(http.StatusOK, okResp{sub})
} }
@ -335,7 +355,6 @@ func handleSubscriberSendOptin(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
out models.Subscribers
) )
if id < 1 { if id < 1 {
@ -343,19 +362,15 @@ func handleSubscriberSendOptin(c echo.Context) error {
} }
// Fetch the subscriber. // Fetch the subscriber.
err := app.queries.GetSubscriber.Select(&out, id, nil) out, err := getSubscriber(id, "", "", app)
if err != nil { if err != nil {
app.log.Printf("error fetching subscriber: %v", err) app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) "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, return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.T("subscribers.errorSendingOptin")) app.i18n.T("subscribers.errorSendingOptin"))
} }
@ -619,56 +634,53 @@ func handleExportSubscriberData(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json", b) return c.Blob(http.StatusOK, "application/json", b)
} }
// insertSubscriber inserts a subscriber and returns the ID. // insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, error) { // 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() uu, err := uuid.NewV4()
if err != nil { if err != nil {
return req.Subscriber, err return req.Subscriber, false, false, err
} }
req.UUID = uu.String() req.UUID = uu.String()
err = app.queries.InsertSubscriber.Get(&req.ID, isNew := true
if err = app.queries.InsertSubscriber.Get(&req.ID,
req.UUID, req.UUID,
req.Email, req.Email,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Name),
req.Status, req.Status,
req.Attribs, req.Attribs,
req.Lists, req.Lists,
req.ListUUIDs) req.ListUUIDs); err != nil {
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return req.Subscriber, errSubscriberExists isNew = false
} else {
// return req.Subscriber, errSubscriberExists
app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
} }
app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
} }
// Fetch the subscriber's full data. // Fetch the subscriber's full data. If the subscriber already existed and wasn't
sub, err := getSubscriber(req.ID, app) // 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 { if err != nil {
return sub, err return sub, false, false, err
} }
// Send a confirmation e-mail (if there are any double opt-in lists). // Send a confirmation e-mail (if there are any double opt-in lists).
_ = sendOptinConfirmation(sub, []int64(req.Lists), app) num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app)
return sub, nil return sub, isNew, num > 0, nil
} }
// getSubscriber gets a single subscriber by ID. // getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order.
func getSubscriber(id int, app *App) (models.Subscriber, error) { // Only one of these params should have a value.
var ( func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) {
out models.Subscribers var out models.Subscribers
)
if id < 1 { if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil {
return models.Subscriber{},
echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil {
app.log.Printf("error fetching subscriber: %v", err) app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", 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 // 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 // if at least one of the given listIDs is set to optin=double. It returns the number of
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) error { // opt-in lists that were found.
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) {
var lists []models.List var lists []models.List
// Fetch double opt-in lists from the given list IDs. // 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, if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil,
pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil { pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return err return 0, err
} }
// None. // None.
if len(lists) == 0 { if len(lists) == 0 {
return nil return 0, nil
} }
var ( var (
@ -764,9 +777,9 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
if err := app.sendNotification([]string{sub.Email}, if err := app.sendNotification([]string{sub.Email},
app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error sending opt-in e-mail: %s", err) 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 // 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()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} }
// TODO: PASSWORD HASHING. res, err := app.queries.UpdateTemplate.Exec(id, o.Name, o.Body)
res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating", 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, "") curVersion = reSemver.ReplaceAllString(curVersion, "")
time.Sleep(time.Second * 1) time.Sleep(time.Second * 1)
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop()
for ; true; <-ticker.C { for ; true; <-ticker.C {
resp, err := http.Get(updateCheckURL) resp, err := http.Get(updateCheckURL)
if err != nil { if err != nil {

View File

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

View File

@ -13,16 +13,16 @@ x-app-defaults: &app-defaults
- listmonk - listmonk
x-db-defaults: &db-defaults x-db-defaults: &db-defaults
image: postgres:11 image: postgres:11
ports: ports:
- "9432:5432" - "9432:5432"
networks: networks:
- listmonk - listmonk
environment: environment:
- POSTGRES_PASSWORD=listmonk - POSTGRES_PASSWORD=listmonk
- POSTGRES_USER=listmonk - POSTGRES_USER=listmonk
- POSTGRES_DB=listmonk - POSTGRES_DB=listmonk
restart: unless-stopped restart: unless-stopped
services: services:
db: db:
@ -43,7 +43,7 @@ services:
demo-app: demo-app:
<<: *app-defaults <<: *app-defaults
command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"] command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"]
depends_on: depends_on:
- demo-db - demo-db
networks: networks:

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. For developer setup instructions, refer to the main project's README.
## Globals ## 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`. Some constants are defined in `constants.js`.
## APIs and states ## 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`. 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. 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 ## 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", "codeflask": "^1.4.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"dayjs": "^1.8.28", "dayjs": "^1.8.28",
"elliptic": "^6.5.3", "elliptic": "^6.5.4",
"humps": "^2.0.1", "humps": "^2.0.1",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"node-forge": "^0.10.0", "node-forge": "^0.10.0",
@ -40,6 +40,8 @@
"@vue/cli-service": "~4.4.0", "@vue/cli-service": "~4.4.0",
"@vue/eslint-config-airbnb": "^5.0.2", "@vue/eslint-config-airbnb": "^5.0.2",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"cypress": "^6.4.0",
"cypress-file-upload": "^5.0.2",
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^6.2.2",

View File

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

View File

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

View File

@ -195,11 +195,17 @@ export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates }); { loading: models.templates });
// Settings. // 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', 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, export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings }); { loading: models.settings });
export const getLogs = async () => http.get('/api/logs', export const getLogs = async () => http.get('/api/logs',
{ loading: models.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"> <section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading> <b-loading :active="isLoading" :is-full-page="false"></b-loading>
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form"> <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" /> <input type="hidden" name="body" :value="body" />
</form> </form>
@ -42,6 +43,7 @@ export default {
// campaign | template. // campaign | template.
type: String, type: String,
body: String, body: String,
contentType: String,
}, },
data() { data() {

View File

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

View File

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

View File

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

View File

@ -1,13 +1,11 @@
import Vue from 'vue'; import Vue from 'vue';
import Buefy from 'buefy'; import Buefy from 'buefy';
import humps from 'humps';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import store from './store'; import store from './store';
import * as api from './api'; import * as api from './api';
import { models } from './constants';
import Utils from './utils'; import Utils from './utils';
// Internationalisation. // Internationalisation.
@ -18,46 +16,33 @@ Vue.use(Buefy, {});
Vue.config.productionTip = false; Vue.config.productionTip = false;
// Globals. // Globals.
const ut = new Utils(i18n); Vue.prototype.$utils = new Utils(i18n);
Vue.mixin({ Vue.prototype.$api = api;
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);
}
new Vue({ new Vue({
router, router,
store, store,
i18n, i18n,
render: (h) => h(App), 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'); }).$mount('#app');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,9 +11,11 @@
<b-field :label="$t('import.mode')"> <b-field :label="$t('import.mode')">
<div> <div>
<b-radio v-model="form.mode" name="mode" <b-radio v-model="form.mode" name="mode"
native-value="subscribe">{{ $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" <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> </div>
</b-field> </b-field>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,31 +32,34 @@
<b-table-column class="actions" align="right"> <b-table-column class="actions" align="right">
<div> <div>
<a href="#" @click.prevent="previewTemplate(props.row)"> <a href="#" @click.prevent="previewTemplate(props.row)" data-cy="btn-preview">
<b-tooltip :label="$t('templates.preview')" type="is-dark"> <b-tooltip :label="$t('templates.preview')" type="is-dark">
<b-icon icon="file-find-outline" size="is-small" /> <b-icon icon="file-find-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="#" @click.prevent="showEditForm(props.row)"> <a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" /> <b-icon icon="pencil-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a href="" @click.prevent="$utils.prompt(`Clone template`, <a href="" @click.prevent="$utils.prompt(`Clone template`,
{ placeholder: 'Name', value: `Copy of ${props.row.name}`}, { placeholder: 'Name', value: `Copy of ${props.row.name}`},
(name) => cloneTemplate(name, props.row))"> (name) => cloneTemplate(name, props.row))"
data-cy="btn-clone">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" /> <b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a v-if="!props.row.isDefault" href="#" <a v-if="!props.row.isDefault" href="#"
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"> @click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"
data-cy="btn-set-default">
<b-tooltip :label="$t('templates.makeDefault')" type="is-dark"> <b-tooltip :label="$t('templates.makeDefault')" type="is-dark">
<b-icon icon="check-circle-outline" size="is-small" /> <b-icon icon="check-circle-outline" size="is-small" />
</b-tooltip> </b-tooltip>
</a> </a>
<a v-if="!props.row.isDefault" <a v-if="!props.row.isDefault"
href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"> href="#" @click.prevent="$utils.confirm(null, () => deleteTemplate(props.row))"
data-cy="btn-delete">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark"> <b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" /> <b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip> </b-tooltip>

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 go 1.13
require ( require (
github.com/Masterminds/sprig/v3 v3.2.2
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/gofrs/uuid v3.2.0+incompatible github.com/gofrs/uuid v3.2.0+incompatible
@ -16,14 +17,16 @@ require (
github.com/labstack/gommon v0.3.0 // indirect github.com/labstack/gommon v0.3.0 // indirect
github.com/lib/pq v1.3.0 github.com/lib/pq v1.3.0
github.com/mailru/easyjson v0.7.6 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/olekukonko/tablewriter v0.0.4 // indirect github.com/olekukonko/tablewriter v0.0.4 // indirect
github.com/rhnvrm/simples3 v0.5.0 github.com/rhnvrm/simples3 v0.5.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 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 golang.org/x/mod v0.3.0
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
jaytaylor.com/html2text v0.0.0-20200220170450-61d9dc4d7195
) )
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 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 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/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 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 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 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 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 h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 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.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 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/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 h1:j0UEFmS7wSjAwKEIkgKBn8PRDfjcuggzr93R9wk53nQ=
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 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 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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 h1:hJFJrU8kaiLmvYt9I/1k1AB7q+qRhHs/afzTfQ3eGqk=
github.com/knadh/goyesql v2.0.0+incompatible/go.mod h1:W0tSzU8l7lYH1Fihj+bdQzkzOwvirrsMNHwkuY22qoY= 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 h1:Orp5ldaxPM4ozKHfu1m7p6iolJFXDGOpF3/jyOgO6ls=
github.com/knadh/goyesql/v2 v2.1.1/go.mod h1:pMzCA130/ZhEIoMmSmbEFXor3A2dxl5L+JllAc/l64s= 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 h1:xQo0Y43CbzOix0tTeE+plIcfs1pTuaUI1/SsvDl2ROI=
github.com/knadh/koanf v0.12.0/go.mod h1:31bzRSM7vS5Vm9LNLo7B2Re1zhLOZT6EQKeodixBikE= 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 h1:J97q7s6bOsyexHPQRsV4GBX+Ru5dXKXhpZaaS5leu4E=
github.com/knadh/smtppool v0.2.1/go.mod h1:3DJHouXAgPDBz0kC50HukOsdapYSwIEfJGwuip46oCA= 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 h1:f5S5BHzZALjuJEgTIOMC9NidEnBJM7Ze6Lu1GHR/lwU=
github.com/knadh/stuffbin v1.1.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4= github.com/knadh/stuffbin v1.1.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/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 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= 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.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 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 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-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 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 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/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 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 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4=
github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE=
github.com/nats-io/nats-server v1.4.1 h1:Ul1oSOGNV/L8kjr4v6l2f9Yet6WY+LevH1/7cRZ/qyA= github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 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/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 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= 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 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:X+WX0hqoKScdoJAw/G3GArfZ6Ygsn8q+6MdocTMKXOw=
github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 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/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.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.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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 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-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-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-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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-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-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 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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/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-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-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-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-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-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 h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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/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-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-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 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 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 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 h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY= 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.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= 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

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

View File

@ -25,6 +25,7 @@
"campaigns.fromAddress": "From address", "campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>", "campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign", "campaigns.invalid": "Invalid campaign",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campaign needs a date to be scheduled.", "campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
"campaigns.newCampaign": "New campaign", "campaigns.newCampaign": "New campaign",
"campaigns.noKnownSubsToTest": "No known subscribers to test.", "campaigns.noKnownSubsToTest": "No known subscribers to test.",
@ -79,7 +80,7 @@
"email.optin.confirmSubHelp": "Confirm your subscription by clicking the below button.", "email.optin.confirmSubHelp": "Confirm your subscription by clicking the below button.",
"email.optin.confirmSubInfo": "You have been added to the following lists:", "email.optin.confirmSubInfo": "You have been added to the following lists:",
"email.optin.confirmSubTitle": "Confirm subscription", "email.optin.confirmSubTitle": "Confirm subscription",
"email.optin.confirmSubWelcome": "Hi {name},", "email.optin.confirmSubWelcome": "Hi",
"email.optin.privateList": "Private list", "email.optin.privateList": "Private list",
"email.status.campaignReason": "Reason", "email.status.campaignReason": "Reason",
"email.status.campaignSent": "Sent", "email.status.campaignSent": "Sent",
@ -92,6 +93,7 @@
"email.unsubHelp": "Don't want to receive these e-mails?", "email.unsubHelp": "Don't want to receive these e-mails?",
"forms.formHTML": "Form HTML", "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.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.publicLists": "Public lists",
"forms.publicSubPage": "Public subscription page", "forms.publicSubPage": "Public subscription page",
"forms.selectHelp": "Select lists to add to the form.", "forms.selectHelp": "Select lists to add to the form.",
@ -111,13 +113,13 @@
"globals.buttons.remove": "Remove", "globals.buttons.remove": "Remove",
"globals.buttons.save": "Save", "globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes", "globals.buttons.saveChanges": "Save changes",
"globals.days.0": "Sun",
"globals.days.1": "Mon", "globals.days.1": "Mon",
"globals.days.2": "Tue", "globals.days.2": "Tue",
"globals.days.3": "Wed", "globals.days.3": "Wed",
"globals.days.4": "Thu", "globals.days.4": "Thu",
"globals.days.5": "Fri", "globals.days.5": "Fri",
"globals.days.6": "Sat", "globals.days.6": "Sat",
"globals.days.7": "Sun",
"globals.fields.createdAt": "Created", "globals.fields.createdAt": "Created",
"globals.fields.id": "ID", "globals.fields.id": "ID",
"globals.fields.name": "Name", "globals.fields.name": "Name",
@ -257,12 +259,13 @@
"public.privacyWipe": "Wipe your data", "public.privacyWipe": "Wipe your data",
"public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.", "public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.",
"public.sub": "Subscribe", "public.sub": "Subscribe",
"public.subTitle": "Subscribe",
"public.subName": "Name (optional)",
"public.subConfirmed": "Subscribed successfully.", "public.subConfirmed": "Subscribed successfully.",
"public.subConfirmedTitle": "Confirmed", "public.subConfirmedTitle": "Confirmed",
"public.subName": "Name (optional)",
"public.subNotFound": "Subscription not found.", "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.subPrivateList": "Private list",
"public.subTitle": "Subscribe",
"public.unsub": "Unsubscribe", "public.unsub": "Unsubscribe",
"public.unsubFull": "Also unsubscribe from all future e-mails.", "public.unsubFull": "Also unsubscribe from all future e-mails.",
"public.unsubHelp": "Do you want to unsubscribe from this mailing list?", "public.unsubHelp": "Do you want to unsubscribe from this mailing list?",
@ -270,13 +273,14 @@
"public.unsubbedInfo": "You have unsubscribed successfully.", "public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed", "public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list", "public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}", "settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}", "settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled", "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.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.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.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.faviconURLHelp": "(Optional) full URL to the static favicon to be displayed on user facing view such as the unsubscription page.",
"settings.general.fromEmail": "Default `from` email", "settings.general.fromEmail": "Default `from` email",
@ -320,6 +324,7 @@
"settings.messengers.url": "URL", "settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL of the Postback server.", "settings.messengers.urlHelp": "Root URL of the Postback server.",
"settings.messengers.username": "Username", "settings.messengers.username": "Username",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.performance.batchSize": "Batch size", "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.batchSizeHelp": "The number of subscribers to pull from the database in a single iteration. Each iteration pulls subscribers from the database, sends messages to them, and then moves on to the next iteration to pull the next batch. This should ideally be higher than the maximum achievable throughput (concurrency * message_rate).",
"settings.performance.concurrency": "Concurrency", "settings.performance.concurrency": "Concurrency",
@ -346,6 +351,7 @@
"settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header", "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.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
"settings.privacy.name": "Privacy", "settings.privacy.name": "Privacy",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "Auth protocol", "settings.smtp.authProtocol": "Auth protocol",
"settings.smtp.customHeaders": "Custom headers", "settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.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.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.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.title": "Settings",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Advanced", "subscribers.advancedQuery": "Advanced",
"subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes", "subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes",
"subscribers.attribs": "Attributes", "subscribers.attribs": "Attributes",
@ -428,4 +435,4 @@
"templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.", "templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.",
"templates.preview": "Preview", "templates.preview": "Preview",
"templates.rawHTML": "Raw HTML" "templates.rawHTML": "Raw HTML"
} }

View File

@ -1,438 +1,438 @@
{ {
"_.code": "fr", "_.code": "fr",
"_.name": "Français (fr)", "_.name": "Français (fr)",
"admin.errorMarshallingConfig": "Erreur lors de la lecture de la configuration : {error}", "admin.errorMarshallingConfig": "Erreur lors de la lecture de la configuration : {error}",
"campaigns.addAltText": "Ajouter un message alternatif en texte brut", "campaigns.addAltText": "Ajouter un message alternatif en texte brut",
"campaigns.cantUpdate": "Impossible de mettre à jour une campagne en cours ou terminée.", "campaigns.cantUpdate": "Impossible de mettre à jour une campagne en cours ou terminée.",
"campaigns.clicks": "clics", "campaigns.clicks": "clics",
"campaigns.confirmDelete": "Supprimer la campagne {name}", "campaigns.confirmDelete": "Supprimer la campagne {name}",
"campaigns.confirmSchedule": "Cette campagne démarrera automatiquement à la date et à l'heure planifiées. Confirmer la planification ?", "campaigns.confirmSchedule": "Cette campagne démarrera automatiquement à la date et à l'heure planifiées. Confirmer la planification ?",
"campaigns.confirmSwitchFormat": "Le contenu peut perdre sa mise en forme. Continuer ?", "campaigns.confirmSwitchFormat": "Le contenu peut perdre sa mise en forme. Continuer ?",
"campaigns.content": "Contenu", "campaigns.content": "Contenu",
"campaigns.contentHelp": "Rédigez le contenu ici.", "campaigns.contentHelp": "Rédigez le contenu ici.",
"campaigns.continue": "Continuer", "campaigns.continue": "Continuer",
"campaigns.copyOf": "Copie de {name}", "campaigns.copyOf": "Copie de {name}",
"campaigns.dateAndTime": "Date et heure", "campaigns.dateAndTime": "Date et heure",
"campaigns.ended": "Terminée", "campaigns.ended": "Terminée",
"campaigns.errorSendTest": "Erreur lors de l'envoi du test : {error}", "campaigns.errorSendTest": "Erreur lors de l'envoi du test : {error}",
"campaigns.fieldInvalidBody": "Erreur lors de la compilation du corps de la campagne : {error}", "campaigns.fieldInvalidBody": "Erreur lors de la compilation du corps de la campagne : {error}",
"campaigns.fieldInvalidFromEmail": "Adresse d'envoi invalide.", "campaigns.fieldInvalidFromEmail": "Adresse d'envoi invalide.",
"campaigns.fieldInvalidListIDs": "ID de liste invalides.", "campaigns.fieldInvalidListIDs": "ID de liste invalides.",
"campaigns.fieldInvalidMessenger": "Service de messagerie inconnu : {name}.", "campaigns.fieldInvalidMessenger": "Service de messagerie inconnu : {name}.",
"campaigns.fieldInvalidName": "Longueur du nom invalide.", "campaigns.fieldInvalidName": "Longueur du nom invalide.",
"campaigns.fieldInvalidSendAt": "La date planifiée doit être future.", "campaigns.fieldInvalidSendAt": "La date planifiée doit être future.",
"campaigns.fieldInvalidSubject": "Longueur d'objet non valide.", "campaigns.fieldInvalidSubject": "Longueur d'objet non valide.",
"campaigns.fromAddress": "Adresse d'envoi", "campaigns.fromAddress": "Adresse d'envoi",
"campaigns.fromAddressPlaceholder": "Nom à afficher <noreply@votresite.com>", "campaigns.fromAddressPlaceholder": "Nom à afficher <noreply@votresite.com>",
"campaigns.invalid": "Campagne non valide", "campaigns.invalid": "Campagne non valide",
"campaigns.markdown": "Markdown", "campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Une date est nécessaire pour planifier la campagne.", "campaigns.needsSendAt": "Une date est nécessaire pour planifier la campagne.",
"campaigns.newCampaign": "Nouvelle campagne", "campaigns.newCampaign": "Nouvelle campagne",
"campaigns.noKnownSubsToTest": "Aucun·e abonné·e connu à tester.", "campaigns.noKnownSubsToTest": "Aucun·e abonné·e connu à tester.",
"campaigns.noOptinLists": "Aucune liste opt-in trouvée pour créer une campagne.", "campaigns.noOptinLists": "Aucune liste opt-in trouvée pour créer une campagne.",
"campaigns.noSubs": "Il n'y a aucun·e abonné·e dans les listes sélectionnées pour créer la campagne.", "campaigns.noSubs": "Il n'y a aucun·e abonné·e dans les listes sélectionnées pour créer la campagne.",
"campaigns.noSubsToTest": "Il n'y a aucun·e abonné·e à cibler.", "campaigns.noSubsToTest": "Il n'y a aucun·e abonné·e à cibler.",
"campaigns.notFound": "Campagne introuvable.", "campaigns.notFound": "Campagne introuvable.",
"campaigns.onlyActiveCancel": "Seules les campagnes actives peuvent être annulées.", "campaigns.onlyActiveCancel": "Seules les campagnes actives peuvent être annulées.",
"campaigns.onlyActivePause": "Seules les campagnes actives peuvent être mises en pause.", "campaigns.onlyActivePause": "Seules les campagnes actives peuvent être mises en pause.",
"campaigns.onlyDraftAsScheduled": "Seuls les campagnes à l'état de brouillon peuvent être planifiées.", "campaigns.onlyDraftAsScheduled": "Seuls les campagnes à l'état de brouillon peuvent être planifiées.",
"campaigns.onlyPausedDraft": "Seuls les brouillons et les campagnes mises en pause peuvent être lancés.", "campaigns.onlyPausedDraft": "Seuls les brouillons et les campagnes mises en pause peuvent être lancés.",
"campaigns.onlyScheduledAsDraft": "Seules les campagnes planifiées peuvent être enregistrées en tant que brouillons.", "campaigns.onlyScheduledAsDraft": "Seules les campagnes planifiées peuvent être enregistrées en tant que brouillons.",
"campaigns.pause": "Pause", "campaigns.pause": "Pause",
"campaigns.plainText": "Texte brut", "campaigns.plainText": "Texte brut",
"campaigns.preview": "Aperçu", "campaigns.preview": "Aperçu",
"campaigns.progress": "Avancement", "campaigns.progress": "Avancement",
"campaigns.queryPlaceholder": "Nom ou objet", "campaigns.queryPlaceholder": "Nom ou objet",
"campaigns.rawHTML": "HTML brut", "campaigns.rawHTML": "HTML brut",
"campaigns.removeAltText": "Supprimer le message alternatif en texte brut", "campaigns.removeAltText": "Supprimer le message alternatif en texte brut",
"campaigns.richText": "Texte riche", "campaigns.richText": "Texte riche",
"campaigns.schedule": "Planifier la campagne", "campaigns.schedule": "Planifier la campagne",
"campaigns.scheduled": "Planifiée", "campaigns.scheduled": "Planifiée",
"campaigns.send": "Envoyer", "campaigns.send": "Envoyer",
"campaigns.sendLater": "Envoyer plus tard", "campaigns.sendLater": "Envoyer plus tard",
"campaigns.sendTest": "Envoyer un message de test", "campaigns.sendTest": "Envoyer un message de test",
"campaigns.sendTestHelp": "Pour ajouter plusieurs destinataires, appuyez sur Entrée après avoir tapé une adresse. Les adresses doivent faire partie des abonné·es existant·es.", "campaigns.sendTestHelp": "Pour ajouter plusieurs destinataires, appuyez sur Entrée après avoir tapé une adresse. Les adresses doivent faire partie des abonné·es existant·es.",
"campaigns.sendToLists": "Envoyer aux listes", "campaigns.sendToLists": "Envoyer aux listes",
"campaigns.sent": "Envoyée", "campaigns.sent": "Envoyée",
"campaigns.start": "Lancer la campagne", "campaigns.start": "Lancer la campagne",
"campaigns.started": "La campagne \"{name}\" est lancée", "campaigns.started": "La campagne \"{name}\" est lancée",
"campaigns.startedAt": "Début", "campaigns.startedAt": "Début",
"campaigns.stats": "Statistiques", "campaigns.stats": "Statistiques",
"campaigns.status.cancelled": "annulée", "campaigns.status.cancelled": "annulée",
"campaigns.status.draft": "en brouillon", "campaigns.status.draft": "en brouillon",
"campaigns.status.finished": "terminée", "campaigns.status.finished": "terminée",
"campaigns.status.paused": "en pause", "campaigns.status.paused": "en pause",
"campaigns.status.running": "active", "campaigns.status.running": "active",
"campaigns.status.scheduled": "planifiée", "campaigns.status.scheduled": "planifiée",
"campaigns.statusChanged": "La campagne \"{name}\" est {status}", "campaigns.statusChanged": "La campagne \"{name}\" est {status}",
"campaigns.subject": "Objet", "campaigns.subject": "Objet",
"campaigns.testEmails": "Emails de test", "campaigns.testEmails": "Emails de test",
"campaigns.testSent": "Message de test envoyé", "campaigns.testSent": "Message de test envoyé",
"campaigns.timestamps": "Horodatages", "campaigns.timestamps": "Horodatages",
"campaigns.views": "Vues", "campaigns.views": "Vues",
"dashboard.campaignViews": "vues de campagne", "dashboard.campaignViews": "vues de campagne",
"dashboard.linkClicks": "clics sur liens", "dashboard.linkClicks": "clics sur liens",
"dashboard.messagesSent": "messages envoyés", "dashboard.messagesSent": "messages envoyés",
"dashboard.orphanSubs": "abonnements sans retour", "dashboard.orphanSubs": "abonnements sans retour",
"email.data.info": "Vous trouverez un fichier au format JSON contenant l'ensemble des données enregistrées à votre sujet en pièce jointe. Il peut être visualisé dans un éditeur de texte.", "email.data.info": "Vous trouverez un fichier au format JSON contenant l'ensemble des données enregistrées à votre sujet en pièce jointe. Il peut être visualisé dans un éditeur de texte.",
"email.data.title": "Vos données personnelles", "email.data.title": "Vos données personnelles",
"email.optin.confirmSub": "Confirmer votre abonnement", "email.optin.confirmSub": "Confirmer votre abonnement",
"email.optin.confirmSubHelp": "Confirmez votre abonnement en cliquant sur le bouton ci-dessous :", "email.optin.confirmSubHelp": "Confirmez votre abonnement en cliquant sur le bouton ci-dessous :",
"email.optin.confirmSubInfo": "Vous avez été ajouté·e aux listes suivantes :", "email.optin.confirmSubInfo": "Vous avez été ajouté·e aux listes suivantes :",
"email.optin.confirmSubTitle": "Confirmer votre abonnement", "email.optin.confirmSubTitle": "Confirmer votre abonnement",
"email.optin.confirmSubWelcome": "Bonjour,", "email.optin.confirmSubWelcome": "Bonjour,",
"email.optin.privateList": "Liste privée", "email.optin.privateList": "Liste privée",
"email.status.campaignReason": "Description", "email.status.campaignReason": "Description",
"email.status.campaignSent": "Envoyée", "email.status.campaignSent": "Envoyée",
"email.status.campaignUpdateTitle": "Mise à jour de campagne", "email.status.campaignUpdateTitle": "Mise à jour de campagne",
"email.status.importFile": "Fichier", "email.status.importFile": "Fichier",
"email.status.importRecords": "Contacts importés", "email.status.importRecords": "Contacts importés",
"email.status.importTitle": "Importer la mise à jour", "email.status.importTitle": "Importer la mise à jour",
"email.status.status": "Statut", "email.status.status": "Statut",
"email.unsub": "Se désabonner", "email.unsub": "Se désabonner",
"email.unsubHelp": "Vous ne souhaitez pas recevoir ces emails ?", "email.unsubHelp": "Vous ne souhaitez pas recevoir ces emails ?",
"forms.formHTML": "Formulaire HTML", "forms.formHTML": "Formulaire HTML",
"forms.formHTMLHelp": "Utilisez le code HTML suivant pour afficher un formulaire d'abonnement sur une page Web externe. Le formulaire doit avoir le champ email et un ou plusieurs champs `l` (listes UUID). Le champ \"nom\" est facultatif.", "forms.formHTMLHelp": "Utilisez le code HTML suivant pour afficher un formulaire d'abonnement sur une page Web externe. Le formulaire doit avoir le champ email et un ou plusieurs champs `l` (listes UUID). Le champ \"nom\" est facultatif.",
"forms.noPublicLists": "Il n'y a pas de listes publiques pour générer un formulaire.", "forms.noPublicLists": "Il n'y a pas de listes publiques pour générer un formulaire.",
"forms.publicLists": "Listes publiques", "forms.publicLists": "Listes publiques",
"forms.publicSubPage": "Page d'abonnement publique", "forms.publicSubPage": "Page d'abonnement publique",
"forms.selectHelp": "Sélectionnez les listes à ajouter au formulaire.", "forms.selectHelp": "Sélectionnez les listes à ajouter au formulaire.",
"forms.title": "Formulaires", "forms.title": "Formulaires",
"globals.buttons.add": "Ajouter", "globals.buttons.add": "Ajouter",
"globals.buttons.addNew": "Ajouter", "globals.buttons.addNew": "Ajouter",
"globals.buttons.cancel": "Annuler", "globals.buttons.cancel": "Annuler",
"globals.buttons.clone": "Cloner", "globals.buttons.clone": "Cloner",
"globals.buttons.close": "Fermer", "globals.buttons.close": "Fermer",
"globals.buttons.continue": "Continuer", "globals.buttons.continue": "Continuer",
"globals.buttons.delete": "Supprimer", "globals.buttons.delete": "Supprimer",
"globals.buttons.edit": "Éditer", "globals.buttons.edit": "Éditer",
"globals.buttons.enabled": "Activé·e", "globals.buttons.enabled": "Activé·e",
"globals.buttons.learnMore": "En savoir plus", "globals.buttons.learnMore": "En savoir plus",
"globals.buttons.new": "Ajouter", "globals.buttons.new": "Ajouter",
"globals.buttons.ok": "Valider", "globals.buttons.ok": "Valider",
"globals.buttons.remove": "Supprimer", "globals.buttons.remove": "Supprimer",
"globals.buttons.save": "Enregistrer", "globals.buttons.save": "Enregistrer",
"globals.buttons.saveChanges": "Enregistrer les changements", "globals.buttons.saveChanges": "Enregistrer les changements",
"globals.days.0": "dim.", "globals.days.0": "dim.",
"globals.days.1": "lun.", "globals.days.1": "lun.",
"globals.days.2": "mar.", "globals.days.2": "mar.",
"globals.days.3": "mer.", "globals.days.3": "mer.",
"globals.days.4": "jeu.", "globals.days.4": "jeu.",
"globals.days.5": "ven.", "globals.days.5": "ven.",
"globals.days.6": "sam.", "globals.days.6": "sam.",
"globals.fields.createdAt": "Créé·e le", "globals.fields.createdAt": "Créé·e le",
"globals.fields.id": "ID", "globals.fields.id": "ID",
"globals.fields.name": "Nom", "globals.fields.name": "Nom",
"globals.fields.status": "Statut", "globals.fields.status": "Statut",
"globals.fields.type": "Type", "globals.fields.type": "Type",
"globals.fields.updatedAt": "Mis à jour le", "globals.fields.updatedAt": "Mis à jour le",
"globals.fields.uuid": "UUID", "globals.fields.uuid": "UUID",
"globals.messages.confirm": "Confirmer ?", "globals.messages.confirm": "Confirmer ?",
"globals.messages.created": "Création de \"{name}\"", "globals.messages.created": "Création de \"{name}\"",
"globals.messages.deleted": "Suppression de \"{name}\"", "globals.messages.deleted": "Suppression de \"{name}\"",
"globals.messages.emptyState": "Rien", "globals.messages.emptyState": "Rien",
"globals.messages.errorCreating": "Erreur lors de la création de {name} : {error}", "globals.messages.errorCreating": "Erreur lors de la création de {name} : {error}",
"globals.messages.errorDeleting": "Erreur lors de la suppression de {name} : {error}", "globals.messages.errorDeleting": "Erreur lors de la suppression de {name} : {error}",
"globals.messages.errorFetching": "Erreur lors de la récupération de {name} : {error}", "globals.messages.errorFetching": "Erreur lors de la récupération de {name} : {error}",
"globals.messages.errorUUID": "Erreur lors de la génération de l'UUID : {error}", "globals.messages.errorUUID": "Erreur lors de la génération de l'UUID : {error}",
"globals.messages.errorUpdating": "Erreur lors de la mise à jour de {name} : {error}", "globals.messages.errorUpdating": "Erreur lors de la mise à jour de {name} : {error}",
"globals.messages.invalidID": "ID invalide", "globals.messages.invalidID": "ID invalide",
"globals.messages.invalidUUID": "UUID invalide", "globals.messages.invalidUUID": "UUID invalide",
"globals.messages.notFound": "{name} introuvable", "globals.messages.notFound": "{name} introuvable",
"globals.messages.passwordChange": "Entrez un nouveau mot de passe pour en changer", "globals.messages.passwordChange": "Entrez un nouveau mot de passe pour en changer",
"globals.messages.updated": "Mise à jour de \"{name}\"", "globals.messages.updated": "Mise à jour de \"{name}\"",
"globals.months.1": "jan.", "globals.months.1": "jan.",
"globals.months.10": "oct.", "globals.months.10": "oct.",
"globals.months.11": "nov.", "globals.months.11": "nov.",
"globals.months.12": "déc.", "globals.months.12": "déc.",
"globals.months.2": "fév.", "globals.months.2": "fév.",
"globals.months.3": "mars", "globals.months.3": "mars",
"globals.months.4": "avr.", "globals.months.4": "avr.",
"globals.months.5": "mai", "globals.months.5": "mai",
"globals.months.6": "juin", "globals.months.6": "juin",
"globals.months.7": "juil.", "globals.months.7": "juil.",
"globals.months.8": "août", "globals.months.8": "août",
"globals.months.9": "sept.", "globals.months.9": "sept.",
"globals.terms.campaign": "Campagne | Campagnes", "globals.terms.campaign": "Campagne | Campagnes",
"globals.terms.campaigns": "Campagnes", "globals.terms.campaigns": "Campagnes",
"globals.terms.dashboard": "Tableau de bord", "globals.terms.dashboard": "Tableau de bord",
"globals.terms.list": "Liste | Listes", "globals.terms.list": "Liste | Listes",
"globals.terms.lists": "Listes", "globals.terms.lists": "Listes",
"globals.terms.media": "Médias | Médias", "globals.terms.media": "Médias | Médias",
"globals.terms.messenger": "Service de messagerie | Services de messagerie", "globals.terms.messenger": "Service de messagerie | Services de messagerie",
"globals.terms.messengers": "Services de messagerie", "globals.terms.messengers": "Services de messagerie",
"globals.terms.settings": "Paramètres", "globals.terms.settings": "Paramètres",
"globals.terms.subscriber": "Abonné·e | Abonné·es", "globals.terms.subscriber": "Abonné·e | Abonné·es",
"globals.terms.subscribers": "Abonné·es", "globals.terms.subscribers": "Abonné·es",
"globals.terms.tag": "Tag | Tags", "globals.terms.tag": "Tag | Tags",
"globals.terms.tags": "Tags", "globals.terms.tags": "Tags",
"globals.terms.template": "Modèle | Modèles", "globals.terms.template": "Modèle | Modèles",
"globals.terms.templates": "Modèles", "globals.terms.templates": "Modèles",
"import.alreadyRunning": "Une importation est déjà en cours. Attendez qu'elle se termine ou arrêtez-la avant de réessayer.", "import.alreadyRunning": "Une importation est déjà en cours. Attendez qu'elle se termine ou arrêtez-la avant de réessayer.",
"import.blocklist": "Bloquer les adresses importées", "import.blocklist": "Bloquer les adresses importées",
"import.csvDelim": "Délimiteur CSV", "import.csvDelim": "Délimiteur CSV",
"import.csvDelimHelp": "Le délimiteur par défaut est la virgule.", "import.csvDelimHelp": "Le délimiteur par défaut est la virgule.",
"import.csvExample": "Exemple de CSV brut", "import.csvExample": "Exemple de CSV brut",
"import.csvFile": "Fichier CSV ou ZIP", "import.csvFile": "Fichier CSV ou ZIP",
"import.csvFileHelp": "Cliquez ou glissez-déposez ici un fichier CSV ou ZIP", "import.csvFileHelp": "Cliquez ou glissez-déposez ici un fichier CSV ou ZIP",
"import.errorCopyingFile": "Erreur lors de la copie du fichier : {error}", "import.errorCopyingFile": "Erreur lors de la copie du fichier : {error}",
"import.errorProcessingZIP": "Erreur lors du traitement du fichier ZIP : {error}", "import.errorProcessingZIP": "Erreur lors du traitement du fichier ZIP : {error}",
"import.errorStarting": "Erreur lors du démarrage de l'importation : {error}", "import.errorStarting": "Erreur lors du démarrage de l'importation : {error}",
"import.importDone": "Importation terminée", "import.importDone": "Importation terminée",
"import.importStarted": "L'importation a commencé", "import.importStarted": "L'importation a commencé",
"import.instructions": "Instructions", "import.instructions": "Instructions",
"import.instructionsHelp": "Téléchargez un fichier CSV (ou un fichier ZIP contenant un seul fichier CSV) pour importer des contacts en masse. Le fichier CSV doit avoir les en-têtes suivantes avec ces noms de colonnes exacts. Les attributs (facultatifs) doivent être des chaînes JSON valides entre guillemets doubles.", "import.instructionsHelp": "Téléchargez un fichier CSV (ou un fichier ZIP contenant un seul fichier CSV) pour importer des contacts en masse. Le fichier CSV doit avoir les en-têtes suivantes avec ces noms de colonnes exacts. Les attributs (facultatifs) doivent être des chaînes JSON valides entre guillemets doubles.",
"import.invalidDelim": "Le délimiteur doit être un seul caractère.", "import.invalidDelim": "Le délimiteur doit être un seul caractère.",
"import.invalidFile": "Fichier non valide : {error}", "import.invalidFile": "Fichier non valide : {error}",
"import.invalidMode": "Mode invalide", "import.invalidMode": "Mode invalide",
"import.invalidParams": "Paramètres non valides : {error}", "import.invalidParams": "Paramètres non valides : {error}",
"import.listSubHelp": "Abonner aux listes", "import.listSubHelp": "Abonner aux listes",
"import.mode": "Mode", "import.mode": "Mode",
"import.overwrite": "Écraser ?", "import.overwrite": "Écraser ?",
"import.overwriteHelp": "Remplacer le nom et les attributs des abonné·es existant·es ?", "import.overwriteHelp": "Remplacer le nom et les attributs des abonné·es existant·es ?",
"import.recordsCount": "{num} / {total} contacts importés", "import.recordsCount": "{num} / {total} contacts importés",
"import.stopImport": "Arrêter l'importation", "import.stopImport": "Arrêter l'importation",
"import.subscribe": "S'abonner", "import.subscribe": "S'abonner",
"import.title": "Importer des abonné·es", "import.title": "Importer des abonné·es",
"import.upload": "Envoyer", "import.upload": "Envoyer",
"lists.confirmDelete": "Êtes-vous sûr·e de supprimer cette liste ? Cela ne supprimera pas les abonné·es.", "lists.confirmDelete": "Êtes-vous sûr·e de supprimer cette liste ? Cela ne supprimera pas les abonné·es.",
"lists.confirmSub": "Confirmer les abonnements à {name}", "lists.confirmSub": "Confirmer les abonnements à {name}",
"lists.invalidName": "Nom incorrect", "lists.invalidName": "Nom incorrect",
"lists.newList": "Nouvelle liste", "lists.newList": "Nouvelle liste",
"lists.optin": "Abonnement \"opt-in\" (ajout par défaut)", "lists.optin": "Abonnement \"opt-in\" (ajout par défaut)",
"lists.optinHelp": "L'option \"opt-in double\" envoie un email à l'abonné·e demandant sa confirmation. Pour les listes en \"opt-in double\", les campagnes ne sont envoyées qu'aux abonné·es s'étant confirmé·es.", "lists.optinHelp": "L'option \"opt-in double\" envoie un email à l'abonné·e demandant sa confirmation. Pour les listes en \"opt-in double\", les campagnes ne sont envoyées qu'aux abonné·es s'étant confirmé·es.",
"lists.optinTo": "Activer l'option opt-in pour {name}", "lists.optinTo": "Activer l'option opt-in pour {name}",
"lists.optins.double": "Opt-in double", "lists.optins.double": "Opt-in double",
"lists.optins.single": "Opt-in simple", "lists.optins.single": "Opt-in simple",
"lists.sendCampaign": "Envoyer la campagne", "lists.sendCampaign": "Envoyer la campagne",
"lists.sendOptinCampaign": "Envoyer une campagne opt-in", "lists.sendOptinCampaign": "Envoyer une campagne opt-in",
"lists.type": "Type", "lists.type": "Type",
"lists.typeHelp": "Les listes publiques sont libres d'accès en abonnement et leurs noms sont visibles sur les pages publiques telles que la page de gestion des abonnements.", "lists.typeHelp": "Les listes publiques sont libres d'accès en abonnement et leurs noms sont visibles sur les pages publiques telles que la page de gestion des abonnements.",
"lists.types.private": "Privée", "lists.types.private": "Privée",
"lists.types.public": "Publique", "lists.types.public": "Publique",
"logs.title": "Logs", "logs.title": "Logs",
"media.errorReadingFile": "Erreur de lecture du fichier : {error}", "media.errorReadingFile": "Erreur de lecture du fichier : {error}",
"media.errorResizing": "Erreur lors du redimensionnement de l'image : {error}", "media.errorResizing": "Erreur lors du redimensionnement de l'image : {error}",
"media.errorSavingThumbnail": "Erreur lors de l'enregistrement de la miniature : {error}", "media.errorSavingThumbnail": "Erreur lors de l'enregistrement de la miniature : {error}",
"media.errorUploading": "Erreur lors de l'envoi du fichier : {error}", "media.errorUploading": "Erreur lors de l'envoi du fichier : {error}",
"media.invalidFile": "Fichier non valide : {error}", "media.invalidFile": "Fichier non valide : {error}",
"media.title": "Fichiers", "media.title": "Fichiers",
"media.unsupportedFileType": "Type de fichier non pris en charge ({type})", "media.unsupportedFileType": "Type de fichier non pris en charge ({type})",
"media.upload": "Importer", "media.upload": "Importer",
"media.uploadHelp": "Cliquez ou glissez-déposez ici une ou plusieurs image(s)", "media.uploadHelp": "Cliquez ou glissez-déposez ici une ou plusieurs image(s)",
"media.uploadImage": "Importer une image", "media.uploadImage": "Importer une image",
"menu.allCampaigns": "Toutes les campagnes", "menu.allCampaigns": "Toutes les campagnes",
"menu.allLists": "Toutes les listes", "menu.allLists": "Toutes les listes",
"menu.allSubscribers": "Tou·tes les abonné·es", "menu.allSubscribers": "Tou·tes les abonné·es",
"menu.dashboard": "Tableau de bord", "menu.dashboard": "Tableau de bord",
"menu.forms": "Formulaires", "menu.forms": "Formulaires",
"menu.import": "Importer", "menu.import": "Importer",
"menu.logs": "Logs", "menu.logs": "Logs",
"menu.media": "Fichiers", "menu.media": "Fichiers",
"menu.newCampaign": "Nouvelle campagne", "menu.newCampaign": "Nouvelle campagne",
"menu.settings": "Paramètres", "menu.settings": "Paramètres",
"public.campaignNotFound": "La liste de diffusion est introuvable.", "public.campaignNotFound": "La liste de diffusion est introuvable.",
"public.confirmOptinSubTitle": "Confirmer votre abonnement", "public.confirmOptinSubTitle": "Confirmer votre abonnement",
"public.confirmSub": "Confirmer votre abonnement", "public.confirmSub": "Confirmer votre abonnement",
"public.confirmSubInfo": "Vous avez été ajouté·e aux listes suivantes :", "public.confirmSubInfo": "Vous avez été ajouté·e aux listes suivantes :",
"public.confirmSubTitle": "Confirmer votre abonnement", "public.confirmSubTitle": "Confirmer votre abonnement",
"public.dataRemoved": "Vos abonnements et toutes les données associées ont été supprimés.", "public.dataRemoved": "Vos abonnements et toutes les données associées ont été supprimés.",
"public.dataRemovedTitle": "Données personnelles supprimées", "public.dataRemovedTitle": "Données personnelles supprimées",
"public.dataSent": "Vos données personnelles vous ont été envoyées par email.", "public.dataSent": "Vos données personnelles vous ont été envoyées par email.",
"public.dataSentTitle": "Données personnelles envoyées", "public.dataSentTitle": "Données personnelles envoyées",
"public.errorFetchingCampaign": "Erreur lors de la récupération de l'email.", "public.errorFetchingCampaign": "Erreur lors de la récupération de l'email.",
"public.errorFetchingEmail": "Email introuvable", "public.errorFetchingEmail": "Email introuvable",
"public.errorFetchingLists": "Erreur lors de la récupération des listes. Veuillez réessayer.", "public.errorFetchingLists": "Erreur lors de la récupération des listes. Veuillez réessayer.",
"public.errorProcessingRequest": "Erreur lors du traitement de la demande. Veuillez réessayer.", "public.errorProcessingRequest": "Erreur lors du traitement de la demande. Veuillez réessayer.",
"public.errorTitle": "Erreur", "public.errorTitle": "Erreur",
"public.invalidFeature": "Cette fonctionnalité n'est pas disponible.", "public.invalidFeature": "Cette fonctionnalité n'est pas disponible.",
"public.invalidLink": "Lien invalide", "public.invalidLink": "Lien invalide",
"public.noListsAvailable": "Aucune liste n'est disponible pour vous abonner.", "public.noListsAvailable": "Aucune liste n'est disponible pour vous abonner.",
"public.noListsSelected": "Aucune liste valide sélectionnée pour s'abonner.", "public.noListsSelected": "Aucune liste valide sélectionnée pour s'abonner.",
"public.noSubInfo": "Il n'y a pas d'abonnement à confirmer.", "public.noSubInfo": "Il n'y a pas d'abonnement à confirmer.",
"public.noSubTitle": "Aucun abonnement", "public.noSubTitle": "Aucun abonnement",
"public.notFoundTitle": "Non trouvé", "public.notFoundTitle": "Non trouvé",
"public.privacyConfirmWipe": "Voulez-vous vraiment supprimer définitivement toutes vos données d'abonnement ?", "public.privacyConfirmWipe": "Voulez-vous vraiment supprimer définitivement toutes vos données d'abonnement ?",
"public.privacyExport": "Exportez vos données personnelles", "public.privacyExport": "Exportez vos données personnelles",
"public.privacyExportHelp": "Une copie de vos données vous sera envoyée par email.", "public.privacyExportHelp": "Une copie de vos données vous sera envoyée par email.",
"public.privacyTitle": "Confidentialité et données personnelles", "public.privacyTitle": "Confidentialité et données personnelles",
"public.privacyWipe": "Effacez toutes vos données personnelles", "public.privacyWipe": "Effacez toutes vos données personnelles",
"public.privacyWipeHelp": "Supprimez définitivement tous vos abonnements et données associées de notre base de données.", "public.privacyWipeHelp": "Supprimez définitivement tous vos abonnements et données associées de notre base de données.",
"public.sub": "S'abonner", "public.sub": "S'abonner",
"public.subConfirmed": "Vous voici abonné·e avec succès.", "public.subConfirmed": "Vous voici abonné·e avec succès.",
"public.subConfirmedTitle": "Abonnement confirmé", "public.subConfirmedTitle": "Abonnement confirmé",
"public.subName": "Nom (facultatif)", "public.subName": "Nom (facultatif)",
"public.subNotFound": "Abonnement introuvable.", "public.subNotFound": "Abonnement introuvable.",
"public.subOptinPending": "Un email de confirmation d'inscription(s) vous a été envoyé.", "public.subOptinPending": "Un email de confirmation d'inscription(s) vous a été envoyé.",
"public.subPrivateList": "Liste privée", "public.subPrivateList": "Liste privée",
"public.subTitle": "S'abonner", "public.subTitle": "S'abonner",
"public.unsub": "Se désabonner", "public.unsub": "Se désabonner",
"public.unsubFull": "Se désabonner également de tous futurs emails.", "public.unsubFull": "Se désabonner également de tous futurs emails.",
"public.unsubHelp": "Voulez-vous vous désabonner de cette liste de diffusion ?", "public.unsubHelp": "Voulez-vous vous désabonner de cette liste de diffusion ?",
"public.unsubTitle": "Se désabonner", "public.unsubTitle": "Se désabonner",
"public.unsubbedInfo": "Vous vous êtes désabonné·e avec succès.", "public.unsubbedInfo": "Vous vous êtes désabonné·e avec succès.",
"public.unsubbedTitle": "Désabonné·e", "public.unsubbedTitle": "Désabonné·e",
"public.unsubscribeTitle": "Se désabonner de la liste de diffusion", "public.unsubscribeTitle": "Se désabonner de la liste de diffusion",
"settings.confirmRestart": "Assurez-vous que les campagnes actives soient en pause. Redémarrer ?", "settings.confirmRestart": "Assurez-vous que les campagnes actives soient en pause. Redémarrer ?",
"settings.duplicateMessengerName": "Doublon du nom de messagerie : {name}", "settings.duplicateMessengerName": "Doublon du nom de messagerie : {name}",
"settings.errorEncoding": "Erreur lors de l'encodage des paramètres : {error}", "settings.errorEncoding": "Erreur lors de l'encodage des paramètres : {error}",
"settings.errorNoSMTP": "Au moins un bloc SMTP doit être activé", "settings.errorNoSMTP": "Au moins un bloc SMTP doit être activé",
"settings.general.adminNotifEmails": "Emails pour les notifications admin", "settings.general.adminNotifEmails": "Emails pour les notifications admin",
"settings.general.adminNotifEmailsHelp": "Liste d'adresses email (séparées par des virgules) auxquelles les notifications d'admin telles que les mises à jour d'importation, fins de campagnes, échecs, etc. seront envoyées.", "settings.general.adminNotifEmailsHelp": "Liste d'adresses email (séparées par des virgules) auxquelles les notifications d'admin telles que les mises à jour d'importation, fins de campagnes, échecs, etc. seront envoyées.",
"settings.general.enablePublicSubPage": "Activer la page d'abonnement publique", "settings.general.enablePublicSubPage": "Activer la page d'abonnement publique",
"settings.general.enablePublicSubPageHelp": "Afficher une page d'abonnement publique avec toutes les listes publiques auxquelles les personnes peuvent s'abonner.", "settings.general.enablePublicSubPageHelp": "Afficher une page d'abonnement publique avec toutes les listes publiques auxquelles les personnes peuvent s'abonner.",
"settings.general.faviconURL": "URL du favicon", "settings.general.faviconURL": "URL du favicon",
"settings.general.faviconURLHelp": "(Facultatif) URL complète du favicon statique visible par l'utilisateur, comme sur la page de désabonnement.", "settings.general.faviconURLHelp": "(Facultatif) URL complète du favicon statique visible par l'utilisateur, comme sur la page de désabonnement.",
"settings.general.fromEmail": "Adresse email `De :` par défaut", "settings.general.fromEmail": "Adresse email `De :` par défaut",
"settings.general.fromEmailHelp": "Adresse email `De :` à afficher par défaut dans les emails de campagne sortants. Ce paramètre est modifiable pour chaque campagne.", "settings.general.fromEmailHelp": "Adresse email `De :` à afficher par défaut dans les emails de campagne sortants. Ce paramètre est modifiable pour chaque campagne.",
"settings.general.language": "Langue", "settings.general.language": "Langue",
"settings.general.logoURL": "URL du logo", "settings.general.logoURL": "URL du logo",
"settings.general.logoURLHelp": "(Facultatif) URL complète du logo statique visible par l'utilisateur, comme sur la page de désabonnement.", "settings.general.logoURLHelp": "(Facultatif) URL complète du logo statique visible par l'utilisateur, comme sur la page de désabonnement.",
"settings.general.name": "Général", "settings.general.name": "Général",
"settings.general.rootURL": "URL racine", "settings.general.rootURL": "URL racine",
"settings.general.rootURLHelp": "URL publique de l'installation (sans slash final)", "settings.general.rootURLHelp": "URL publique de l'installation (sans slash final)",
"settings.invalidMessengerName": "Nom de messagerie invalide", "settings.invalidMessengerName": "Nom de messagerie invalide",
"settings.media.provider": "Fournisseur", "settings.media.provider": "Fournisseur",
"settings.media.s3.bucket": "Compartiment", "settings.media.s3.bucket": "Compartiment",
"settings.media.s3.bucketPath": "Chemin du compartiment", "settings.media.s3.bucketPath": "Chemin du compartiment",
"settings.media.s3.bucketPathHelp": "Emplacement dans le compartiment pour la mise en ligne des fichiers. La valeur par défaut est /", "settings.media.s3.bucketPathHelp": "Emplacement dans le compartiment pour la mise en ligne des fichiers. La valeur par défaut est /",
"settings.media.s3.bucketType": "Type du compartiment", "settings.media.s3.bucketType": "Type du compartiment",
"settings.media.s3.bucketTypePrivate": "Privé", "settings.media.s3.bucketTypePrivate": "Privé",
"settings.media.s3.bucketTypePublic": "Public", "settings.media.s3.bucketTypePublic": "Public",
"settings.media.s3.key": "Clé d'accès AWS", "settings.media.s3.key": "Clé d'accès AWS",
"settings.media.s3.region": "Région", "settings.media.s3.region": "Région",
"settings.media.s3.secret": "Mot de passe d'accès AWS", "settings.media.s3.secret": "Mot de passe d'accès AWS",
"settings.media.s3.uploadExpiry": "Durée de validité", "settings.media.s3.uploadExpiry": "Durée de validité",
"settings.media.s3.uploadExpiryHelp": "(Facultatif) Spécifiez la durée de validité (en secondes) pour l'URL prédéfinie générée. Uniquement applicable pour les compartiments privés (s, m, h, d pour les secondes, minutes, heures, jours).", "settings.media.s3.uploadExpiryHelp": "(Facultatif) Spécifiez la durée de validité (en secondes) pour l'URL prédéfinie générée. Uniquement applicable pour les compartiments privés (s, m, h, d pour les secondes, minutes, heures, jours).",
"settings.media.title": "Mise en ligne de fichiers", "settings.media.title": "Mise en ligne de fichiers",
"settings.media.upload.path": "Emplacement d'envoi des fichiers", "settings.media.upload.path": "Emplacement d'envoi des fichiers",
"settings.media.upload.pathHelp": "Chemin vers le répertoire où les médias seront mis en ligne", "settings.media.upload.pathHelp": "Chemin vers le répertoire où les médias seront mis en ligne",
"settings.media.upload.uri": "URI d'envoi des fichiers", "settings.media.upload.uri": "URI d'envoi des fichiers",
"settings.media.upload.uriHelp": "URI d'envoi des fichiers (qui sera visible du monde extérieur). Les médias stockés à cet emplacement seront accessible publiquement sous {root_url}, par exemple à l'adresse : https://listmonk.votresite.com/uploads", "settings.media.upload.uriHelp": "URI d'envoi des fichiers (qui sera visible du monde extérieur). Les médias stockés à cet emplacement seront accessible publiquement sous {root_url}, par exemple à l'adresse : https://listmonk.votresite.com/uploads",
"settings.messengers.maxConns": "Nombre de connexions max.", "settings.messengers.maxConns": "Nombre de connexions max.",
"settings.messengers.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur", "settings.messengers.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur",
"settings.messengers.messageDiscard": "Annuler les modifications ?", "settings.messengers.messageDiscard": "Annuler les modifications ?",
"settings.messengers.messageSaved": "Paramètres sauvegardés. Redémarrage de l'application...", "settings.messengers.messageSaved": "Paramètres sauvegardés. Redémarrage de l'application...",
"settings.messengers.name": "Nom du service d'envoi de messages", "settings.messengers.name": "Nom du service d'envoi de messages",
"settings.messengers.nameHelp": "Par exemple : my-sms. Utilisez uniquement des caractères alphanumériques et des tirets.", "settings.messengers.nameHelp": "Par exemple : my-sms. Utilisez uniquement des caractères alphanumériques et des tirets.",
"settings.messengers.password": "Mot de passe", "settings.messengers.password": "Mot de passe",
"settings.messengers.retries": "Tentatives de renvoi", "settings.messengers.retries": "Tentatives de renvoi",
"settings.messengers.retriesHelp": "Nombre de tentatives de renvoi en cas d'échec", "settings.messengers.retriesHelp": "Nombre de tentatives de renvoi en cas d'échec",
"settings.messengers.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS", "settings.messengers.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS",
"settings.messengers.timeout": "Délai d'inactivité", "settings.messengers.timeout": "Délai d'inactivité",
"settings.messengers.timeoutHelp": "Temps d'attente d'une nouvelle activité sur la connexion avant sa fermeture et suppression du pool (s pour seconde, m pour minute).", "settings.messengers.timeoutHelp": "Temps d'attente d'une nouvelle activité sur la connexion avant sa fermeture et suppression du pool (s pour seconde, m pour minute).",
"settings.messengers.url": "URL", "settings.messengers.url": "URL",
"settings.messengers.urlHelp": "URL racine du serveur Postback", "settings.messengers.urlHelp": "URL racine du serveur Postback",
"settings.messengers.username": "Nom d'utilisateur", "settings.messengers.username": "Nom d'utilisateur",
"settings.needsRestart": "Certains paramètres ont été modifiés. Mettez toutes les campagnes actives en pause et redémarrez l'application.", "settings.needsRestart": "Certains paramètres ont été modifiés. Mettez toutes les campagnes actives en pause et redémarrez l'application.",
"settings.performance.batchSize": "Taille du lot", "settings.performance.batchSize": "Taille du lot",
"settings.performance.batchSizeHelp": "Le nombre d'abonné·es à extraire de la base de données en une seule itération. Chaque itération extrait les abonné·es de la base de données, leur envoie les messages, puis passe à l'itération suivante pour extraire le lot suivant. Idéalement cette valeur devrait être supérieure au débit maximum possible (Nb de threads * débit).", "settings.performance.batchSizeHelp": "Le nombre d'abonné·es à extraire de la base de données en une seule itération. Chaque itération extrait les abonné·es de la base de données, leur envoie les messages, puis passe à l'itération suivante pour extraire le lot suivant. Idéalement cette valeur devrait être supérieure au débit maximum possible (Nb de threads * débit).",
"settings.performance.concurrency": "Nombre de threads", "settings.performance.concurrency": "Nombre de threads",
"settings.performance.concurrencyHelp": "Nombre de workers (threads) concurrents maximum qui enverrons les messages simultanément.", "settings.performance.concurrencyHelp": "Nombre de workers (threads) concurrents maximum qui enverrons les messages simultanément.",
"settings.performance.maxErrThreshold": "Seuil maximum d'erreurs", "settings.performance.maxErrThreshold": "Seuil maximum d'erreurs",
"settings.performance.maxErrThresholdHelp": "Le nombre d'erreurs (par exemple : délais d'expiration SMTP lors de l'envoi d'emails) qu'une campagne en cours d'exécution doit tolérer avant d'être suspendue pour une vérification ou une intervention manuelle. Réglez sur 0 pour ne jamais mettre en pause.", "settings.performance.maxErrThresholdHelp": "Le nombre d'erreurs (par exemple : délais d'expiration SMTP lors de l'envoi d'emails) qu'une campagne en cours d'exécution doit tolérer avant d'être suspendue pour une vérification ou une intervention manuelle. Réglez sur 0 pour ne jamais mettre en pause.",
"settings.performance.messageRate": "Débit de messages (par thread)", "settings.performance.messageRate": "Débit de messages (par thread)",
"settings.performance.messageRateHelp": "Nombre maximum de messages à envoyer par worker / thread en une seconde. Si concurrence = 10 et débit = 10, alors jusqu'à 10x10 = 100 messages peuvent être mis en file d'envoi chaque seconde. Réglez les deux paramètres afin que le débit total soit inférieur aux seuils fixés par les serveurs de messagerie cibles de vos abonné·es pour ne pas finir en spam.", "settings.performance.messageRateHelp": "Nombre maximum de messages à envoyer par worker / thread en une seconde. Si concurrence = 10 et débit = 10, alors jusqu'à 10x10 = 100 messages peuvent être mis en file d'envoi chaque seconde. Réglez les deux paramètres afin que le débit total soit inférieur aux seuils fixés par les serveurs de messagerie cibles de vos abonné·es pour ne pas finir en spam.",
"settings.performance.name": "Débits et performances", "settings.performance.name": "Débits et performances",
"settings.performance.slidingWindow": "Activer une limite d'envois par fenêtre glissante (max. X messages envoyés sur une durée donnée)", "settings.performance.slidingWindow": "Activer une limite d'envois par fenêtre glissante (max. X messages envoyés sur une durée donnée)",
"settings.performance.slidingWindowDuration": "Durée de la fenêtre", "settings.performance.slidingWindowDuration": "Durée de la fenêtre",
"settings.performance.slidingWindowDurationHelp": "Durée de la fenêtre glissante (m pour minute, h pour heure).", "settings.performance.slidingWindowDurationHelp": "Durée de la fenêtre glissante (m pour minute, h pour heure).",
"settings.performance.slidingWindowHelp": "Limitez le nombre total de messages envoyés au cours d'une période donnée. Une fois cette limite atteinte, l'envoi des messages est suspendu jusqu'à ce que la fenêtre de temps soit écoulée.", "settings.performance.slidingWindowHelp": "Limitez le nombre total de messages envoyés au cours d'une période donnée. Une fois cette limite atteinte, l'envoi des messages est suspendu jusqu'à ce que la fenêtre de temps soit écoulée.",
"settings.performance.slidingWindowRate": "Nb. de messages max", "settings.performance.slidingWindowRate": "Nb. de messages max",
"settings.performance.slidingWindowRateHelp": "Nombre maximum de messages à envoyer sur cette fenêtre", "settings.performance.slidingWindowRateHelp": "Nombre maximum de messages à envoyer sur cette fenêtre",
"settings.privacy.allowBlocklist": "Autoriser les abonné·es à bloquer tout envoi", "settings.privacy.allowBlocklist": "Autoriser les abonné·es à bloquer tout envoi",
"settings.privacy.allowBlocklistHelp": "Autoriser les abonné·es à se désabonner de toutes les listes de diffusion et à se marquer comme étant bloqué·es ?", "settings.privacy.allowBlocklistHelp": "Autoriser les abonné·es à se désabonner de toutes les listes de diffusion et à se marquer comme étant bloqué·es ?",
"settings.privacy.allowExport": "Autoriser l'export des données par les abonné·es", "settings.privacy.allowExport": "Autoriser l'export des données par les abonné·es",
"settings.privacy.allowExportHelp": "Autoriser les abonné·es à exporter les données collectées à leur sujet ?", "settings.privacy.allowExportHelp": "Autoriser les abonné·es à exporter les données collectées à leur sujet ?",
"settings.privacy.allowWipe": "Autoriser la suppression des données par les abonné·es", "settings.privacy.allowWipe": "Autoriser la suppression des données par les abonné·es",
"settings.privacy.allowWipeHelp": "Autoriser les abonné·es à supprimer leurs abonnements et toutes les autres données de la base de données. Les vues de campagne et les clics sur les liens sont également supprimés, tandis que le compteur de vues et de nombre de clics globaux restent inchangés (aucun·e abonné·e ne leur est associé) afin que les statistiques et les analyses ne soient pas affectées.", "settings.privacy.allowWipeHelp": "Autoriser les abonné·es à supprimer leurs abonnements et toutes les autres données de la base de données. Les vues de campagne et les clics sur les liens sont également supprimés, tandis que le compteur de vues et de nombre de clics globaux restent inchangés (aucun·e abonné·e ne leur est associé) afin que les statistiques et les analyses ne soient pas affectées.",
"settings.privacy.individualSubTracking": "Suivi individuel des abonné·es (vérifiez si la légalislation l'autorise)", "settings.privacy.individualSubTracking": "Suivi individuel des abonné·es (vérifiez si la légalislation l'autorise)",
"settings.privacy.individualSubTrackingHelp": "Suivez les vues et les clics par abonné·e pour les campagnes (vérifiez si la légalislation en vigueur l'autorise). Si l'option est désactivée, le suivi des vues et des clics s'effectue de façon anonyme.", "settings.privacy.individualSubTrackingHelp": "Suivez les vues et les clics par abonné·e pour les campagnes (vérifiez si la légalislation en vigueur l'autorise). Si l'option est désactivée, le suivi des vues et des clics s'effectue de façon anonyme.",
"settings.privacy.listUnsubHeader": "Inclure l'en-tête de désabonnement simplifié (via certaines messageries)", "settings.privacy.listUnsubHeader": "Inclure l'en-tête de désabonnement simplifié (via certaines messageries)",
"settings.privacy.listUnsubHeaderHelp": "Inclure des en-têtes de désabonnement qui permettent aux utilisateurs de se désabonner en un seul clic depuis leur client de messagerie.", "settings.privacy.listUnsubHeaderHelp": "Inclure des en-têtes de désabonnement qui permettent aux utilisateurs de se désabonner en un seul clic depuis leur client de messagerie.",
"settings.privacy.name": "Vie privée", "settings.privacy.name": "Vie privée",
"settings.restart": "Redémarrer", "settings.restart": "Redémarrer",
"settings.smtp.authProtocol": "Protocole d'authentification", "settings.smtp.authProtocol": "Protocole d'authentification",
"settings.smtp.customHeaders": "En-têtes personnalisées", "settings.smtp.customHeaders": "En-têtes personnalisées",
"settings.smtp.customHeadersHelp": "Tableau facultatif d'en-têtes à inclure dans tous les emails envoyés depuis ce serveur. Par exemple : [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.customHeadersHelp": "Tableau facultatif d'en-têtes à inclure dans tous les emails envoyés depuis ce serveur. Par exemple : [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
"settings.smtp.enabled": "Activé", "settings.smtp.enabled": "Activé",
"settings.smtp.heloHost": "Nom d'hôte HELO", "settings.smtp.heloHost": "Nom d'hôte HELO",
"settings.smtp.heloHostHelp": "Facultatif. Certains serveurs SMTP nécessitent un nom de domaine complet dans le nom d'hôte. Par défaut, HELOs utilise `localhost`. Définissez ce paramètre si un nom d'hôte personnalisé doit être utilisé.", "settings.smtp.heloHostHelp": "Facultatif. Certains serveurs SMTP nécessitent un nom de domaine complet dans le nom d'hôte. Par défaut, HELOs utilise `localhost`. Définissez ce paramètre si un nom d'hôte personnalisé doit être utilisé.",
"settings.smtp.host": "Hôte", "settings.smtp.host": "Hôte",
"settings.smtp.hostHelp": "Adresse hôte du serveur SMTP", "settings.smtp.hostHelp": "Adresse hôte du serveur SMTP",
"settings.smtp.idleTimeout": "Délai d'inactivité", "settings.smtp.idleTimeout": "Délai d'inactivité",
"settings.smtp.idleTimeoutHelp": "Temps d'attente d'une nouvelle activité sur la connexion avant sa fermeture et suppression du pool (s pour seconde, m pour minute)", "settings.smtp.idleTimeoutHelp": "Temps d'attente d'une nouvelle activité sur la connexion avant sa fermeture et suppression du pool (s pour seconde, m pour minute)",
"settings.smtp.maxConns": "Nb. de connexions max.", "settings.smtp.maxConns": "Nb. de connexions max.",
"settings.smtp.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur SMTP", "settings.smtp.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur SMTP",
"settings.smtp.name": "SMTP", "settings.smtp.name": "SMTP",
"settings.smtp.password": "Mot de passe", "settings.smtp.password": "Mot de passe",
"settings.smtp.passwordHelp": "Entrez un nouveau mot de passe si vous souhaitez le modifier", "settings.smtp.passwordHelp": "Entrez un nouveau mot de passe si vous souhaitez le modifier",
"settings.smtp.port": "Port", "settings.smtp.port": "Port",
"settings.smtp.portHelp": "Port du serveur SMTP", "settings.smtp.portHelp": "Port du serveur SMTP",
"settings.smtp.retries": "Tentatives de renvoi", "settings.smtp.retries": "Tentatives de renvoi",
"settings.smtp.retriesHelp": "Nombre de tentatives de renvoi d'un message en cas d'échec", "settings.smtp.retriesHelp": "Nombre de tentatives de renvoi d'un message en cas d'échec",
"settings.smtp.setCustomHeaders": "Définir des en-têtes personnalisés", "settings.smtp.setCustomHeaders": "Définir des en-têtes personnalisés",
"settings.smtp.skipTLS": "Ignorer la vérification TLS", "settings.smtp.skipTLS": "Ignorer la vérification TLS",
"settings.smtp.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS", "settings.smtp.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS",
"settings.smtp.tls": "TLS", "settings.smtp.tls": "TLS",
"settings.smtp.tlsHelp": "Activer STARTTLS", "settings.smtp.tlsHelp": "Activer STARTTLS",
"settings.smtp.username": "Nom d'utilisateur", "settings.smtp.username": "Nom d'utilisateur",
"settings.smtp.waitTimeout": "Délai d'attente", "settings.smtp.waitTimeout": "Délai d'attente",
"settings.smtp.waitTimeoutHelp": "Temps d'attente d'une nouvelle activité sur une connexion avant sa fermeture et sa suppression du pool (s pour seconde, m pour minute)", "settings.smtp.waitTimeoutHelp": "Temps d'attente d'une nouvelle activité sur une connexion avant sa fermeture et sa suppression du pool (s pour seconde, m pour minute)",
"settings.title": "Paramètres", "settings.title": "Paramètres",
"settings.updateAvailable": "Une nouvelle version ({version}) est disponible.", "settings.updateAvailable": "Une nouvelle version ({version}) est disponible.",
"subscribers.advancedQuery": "Requête avancée", "subscribers.advancedQuery": "Requête avancée",
"subscribers.advancedQueryHelp": "Expression SQL partielle pour interroger les attributs de l'abonné·e", "subscribers.advancedQueryHelp": "Expression SQL partielle pour interroger les attributs de l'abonné·e",
"subscribers.attribs": "Attributs", "subscribers.attribs": "Attributs",
"subscribers.attribsHelp": "Les attributs sont définis comme une map JSON, par exemple :", "subscribers.attribsHelp": "Les attributs sont définis comme une map JSON, par exemple :",
"subscribers.blocklistedHelp": "Les abonné·es bloqué·es ne recevront jamais d'emails.", "subscribers.blocklistedHelp": "Les abonné·es bloqué·es ne recevront jamais d'emails.",
"subscribers.confirmBlocklist": "Bloquer {num} abonné·e(s) ?", "subscribers.confirmBlocklist": "Bloquer {num} abonné·e(s) ?",
"subscribers.confirmDelete": "Supprimer {num} abonné·e(s) ?", "subscribers.confirmDelete": "Supprimer {num} abonné·e(s) ?",
"subscribers.confirmExport": "Exporter {num} abonné·e(s) ?", "subscribers.confirmExport": "Exporter {num} abonné·e(s) ?",
"subscribers.downloadData": "Télécharger les données", "subscribers.downloadData": "Télécharger les données",
"subscribers.email": "Email", "subscribers.email": "Email",
"subscribers.emailExists": "Cet email existe déjà.", "subscribers.emailExists": "Cet email existe déjà.",
"subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}", "subscribers.errorBlocklisting": "Erreur lors du blocage des abonné·es : {error}",
"subscribers.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}", "subscribers.errorInvalidIDs": "Un ou plusieurs identifiants non valides fournis : {error}",
"subscribers.errorNoIDs": "Aucun identifiant fourni.", "subscribers.errorNoIDs": "Aucun identifiant fourni.",
"subscribers.errorNoListsGiven": "Aucune liste attribuée.", "subscribers.errorNoListsGiven": "Aucune liste attribuée.",
"subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}", "subscribers.errorPreparingQuery": "Erreur lors de la préparation de la requête d'abonné·e : {error}",
"subscribers.errorSendingOptin": "Erreur lors de l'envoi de l'email d'opt-in.", "subscribers.errorSendingOptin": "Erreur lors de l'envoi de l'email d'opt-in.",
"subscribers.export": "Export", "subscribers.export": "Export",
"subscribers.invalidAction": "Cette action est invalide.", "subscribers.invalidAction": "Cette action est invalide.",
"subscribers.invalidEmail": "Cet email est invalide.", "subscribers.invalidEmail": "Cet email est invalide.",
"subscribers.invalidJSON": "JSON non valide dans les attributs.", "subscribers.invalidJSON": "JSON non valide dans les attributs.",
"subscribers.invalidName": "Le nom entré présente une erreur.", "subscribers.invalidName": "Le nom entré présente une erreur.",
"subscribers.listChangeApplied": "Modification de la liste effectuée.", "subscribers.listChangeApplied": "Modification de la liste effectuée.",
"subscribers.lists": "Listes", "subscribers.lists": "Listes",
"subscribers.listsHelp": "Les listes dont les abonné·es se sont déjà désabonné·es ne peuvent pas être supprimées.", "subscribers.listsHelp": "Les listes dont les abonné·es se sont déjà désabonné·es ne peuvent pas être supprimées.",
"subscribers.listsPlaceholder": "Listes auxquelles s'abonner", "subscribers.listsPlaceholder": "Listes auxquelles s'abonner",
"subscribers.manageLists": "Gérer les listes", "subscribers.manageLists": "Gérer les listes",
"subscribers.markUnsubscribed": "Marquer comme désabonné·e", "subscribers.markUnsubscribed": "Marquer comme désabonné·e",
"subscribers.newSubscriber": "Nouvel·le abonné·e", "subscribers.newSubscriber": "Nouvel·le abonné·e",
"subscribers.numSelected": "{num} abonné·e(s) sélectionné·e(s)", "subscribers.numSelected": "{num} abonné·e(s) sélectionné·e(s)",
"subscribers.optinSubject": "Confirmer votre abonnement", "subscribers.optinSubject": "Confirmer votre abonnement",
"subscribers.query": "Requête", "subscribers.query": "Requête",
"subscribers.queryPlaceholder": "Email ou nom", "subscribers.queryPlaceholder": "Email ou nom",
"subscribers.reset": "Réinitialiser", "subscribers.reset": "Réinitialiser",
"subscribers.selectAll": "Sélectionner tout {num}", "subscribers.selectAll": "Sélectionner tout {num}",
"subscribers.status.blocklisted": "Bloqué·e", "subscribers.status.blocklisted": "Bloqué·e",
"subscribers.status.confirmed": "Confirmé·e", "subscribers.status.confirmed": "Confirmé·e",
"subscribers.status.enabled": "Activé·e", "subscribers.status.enabled": "Activé·e",
"subscribers.status.subscribed": "Abonné·e", "subscribers.status.subscribed": "Abonné·e",
"subscribers.status.unconfirmed": "Non confirmé·e", "subscribers.status.unconfirmed": "Non confirmé·e",
"subscribers.status.unsubscribed": "Désabonné·e", "subscribers.status.unsubscribed": "Désabonné·e",
"subscribers.subscribersDeleted": "{num} abonné·e(s) supprimé·e(s)", "subscribers.subscribersDeleted": "{num} abonné·e(s) supprimé·e(s)",
"templates.cantDeleteDefault": "Impossible de supprimer le modèle par défaut", "templates.cantDeleteDefault": "Impossible de supprimer le modèle par défaut",
"templates.default": "Défaut", "templates.default": "Défaut",
"templates.dummyName": "Campagne de test", "templates.dummyName": "Campagne de test",
"templates.dummySubject": "Objet de la campagne de test", "templates.dummySubject": "Objet de la campagne de test",
"templates.errorCompiling": "Erreur lors de la compilation du modèle : {error}", "templates.errorCompiling": "Erreur lors de la compilation du modèle : {error}",
"templates.errorRendering": "Message d'erreur lors du rendu : {error}", "templates.errorRendering": "Message d'erreur lors du rendu : {error}",
"templates.fieldInvalidName": "Longueur du nom invalide.", "templates.fieldInvalidName": "Longueur du nom invalide.",
"templates.makeDefault": "Définir par défaut", "templates.makeDefault": "Définir par défaut",
"templates.newTemplate": "Nouveau modèle", "templates.newTemplate": "Nouveau modèle",
"templates.placeholderHelp": "L'espace réservé {placeholder} doit apparaître exactement une fois dans le modèle.", "templates.placeholderHelp": "L'espace réservé {placeholder} doit apparaître exactement une fois dans le modèle.",
"templates.preview": "Aperçu", "templates.preview": "Aperçu",
"templates.rawHTML": "HTML brut" "templates.rawHTML": "HTML brut"
} }

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", "_.code": "ml",
"_.name": "മലയാളം (ml)", "_.name": "മലയാളം (ml)",
"admin.errorMarshallingConfig": "അഭ്യർത്ഥന ക്രമീകരിയ്ക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}", "admin.errorMarshallingConfig": "അഭ്യർത്ഥന ക്രമീകരിയ്ക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
"campaigns.addAltText": "Add alternate plain text message",
"campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.", "campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.",
"campaigns.clicks": "ക്ലീക്കുകൾ", "campaigns.clicks": "ക്ലീക്കുകൾ",
"campaigns.confirmDelete": "{name} നീക്കം ചെയ്യുക", "campaigns.confirmDelete": "{name} നീക്കം ചെയ്യുക",
@ -24,6 +25,7 @@
"campaigns.fromAddress": "പ്രേക്ഷകൻ", "campaigns.fromAddress": "പ്രേക്ഷകൻ",
"campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>", "campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>",
"campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്", "campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.", "campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.",
"campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ", "campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ",
"campaigns.noKnownSubsToTest": "ടെസ്റ്റ് ചെയ്യാൻ, വരിക്കാരുടെ പട്ടിക ശൂന്യമാണ്.", "campaigns.noKnownSubsToTest": "ടെസ്റ്റ് ചെയ്യാൻ, വരിക്കാരുടെ പട്ടിക ശൂന്യമാണ്.",
@ -42,6 +44,7 @@
"campaigns.progress": "പുരോഗതി", "campaigns.progress": "പുരോഗതി",
"campaigns.queryPlaceholder": "പേരോ വിഷയമോ", "campaigns.queryPlaceholder": "പേരോ വിഷയമോ",
"campaigns.rawHTML": "അസംസ്കൃത എച്. ടി. എം. എൽ", "campaigns.rawHTML": "അസംസ്കൃത എച്. ടി. എം. എൽ",
"campaigns.removeAltText": "Remove alternate plain text message",
"campaigns.richText": "റിച്ച് ടെക്സ്റ്റ്", "campaigns.richText": "റിച്ച് ടെക്സ്റ്റ്",
"campaigns.schedule": "ക്യാമ്പേയ്ൻ ആസൂത്രണം ചെയ്യുക", "campaigns.schedule": "ക്യാമ്പേയ്ൻ ആസൂത്രണം ചെയ്യുക",
"campaigns.scheduled": "ആസൂത്രണം ചെയ്തു", "campaigns.scheduled": "ആസൂത്രണം ചെയ്തു",
@ -77,7 +80,7 @@
"email.optin.confirmSubHelp": "നിങ്ങൾ വരിക്കാരനാകുന്നത് താഴെയുള്ള ബട്ടണിൽ ഞെക്കിക്കൊണ്ട് സ്ഥിരീകരിക്കുക.", "email.optin.confirmSubHelp": "നിങ്ങൾ വരിക്കാരനാകുന്നത് താഴെയുള്ള ബട്ടണിൽ ഞെക്കിക്കൊണ്ട് സ്ഥിരീകരിക്കുക.",
"email.optin.confirmSubInfo": "നിങ്ങൾ താഴെപ്പറയുന്ന ലിസ്റ്റുകളിൽ അംഗമാണ്:", "email.optin.confirmSubInfo": "നിങ്ങൾ താഴെപ്പറയുന്ന ലിസ്റ്റുകളിൽ അംഗമാണ്:",
"email.optin.confirmSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക", "email.optin.confirmSubTitle": "വരിക്കാരനാകുന്നത് സ്ഥിരീകരിക്കുക",
"email.optin.confirmSubWelcome": "നമസ്കാരം {name},", "email.optin.confirmSubWelcome": "നമസ്കാരം",
"email.optin.privateList": "സ്വകാര്യ ലിസ്റ്റ്", "email.optin.privateList": "സ്വകാര്യ ലിസ്റ്റ്",
"email.status.campaignReason": "കാരണം", "email.status.campaignReason": "കാരണം",
"email.status.campaignSent": "അയച്ചു", "email.status.campaignSent": "അയച്ചു",
@ -90,7 +93,9 @@
"email.unsubHelp": "ഈ-മെയിലുകൾ ഇനി സ്വീകരിക്കേണ്ടതില്ലേ?", "email.unsubHelp": "ഈ-മെയിലുകൾ ഇനി സ്വീകരിക്കേണ്ടതില്ലേ?",
"forms.formHTML": "എച്. ടി. എം. എൽ ഫോം", "forms.formHTML": "എച്. ടി. എം. എൽ ഫോം",
"forms.formHTMLHelp": "മറ്റൊരു വെബ് പേജിൽ സബ്സ്ക്രിപ്ഷൻ ഫോം കാണിയ്ക്കുന്നതിന് താഴെക്കൊടുത്തിരിക്കുന്ന എച്. ടി. എം. എൽ ഉപയോഗിക്കുക.", "forms.formHTMLHelp": "മറ്റൊരു വെബ് പേജിൽ സബ്സ്ക്രിപ്ഷൻ ഫോം കാണിയ്ക്കുന്നതിന് താഴെക്കൊടുത്തിരിക്കുന്ന എച്. ടി. എം. എൽ ഉപയോഗിക്കുക.",
"forms.noPublicLists": "There are no public lists to generate a forms.",
"forms.publicLists": "പൊതു ലിസ്റ്റുകൾ", "forms.publicLists": "പൊതു ലിസ്റ്റുകൾ",
"forms.publicSubPage": "Public subscription page",
"forms.selectHelp": "ഫോമിലേയ്ക്ക് ചേർക്കേണ്ട ലിസ്റ്റുകൾ.", "forms.selectHelp": "ഫോമിലേയ്ക്ക് ചേർക്കേണ്ട ലിസ്റ്റുകൾ.",
"forms.title": "ഫോമുകൾ", "forms.title": "ഫോമുകൾ",
"globals.buttons.add": "ചേർക്കുക", "globals.buttons.add": "ചേർക്കുക",
@ -108,13 +113,13 @@
"globals.buttons.remove": "നീക്കം ചെയ്യുക", "globals.buttons.remove": "നീക്കം ചെയ്യുക",
"globals.buttons.save": "സൂക്ഷിക്കുക", "globals.buttons.save": "സൂക്ഷിക്കുക",
"globals.buttons.saveChanges": "മാറ്റങ്ങൾ സൂക്ഷിക്കുക", "globals.buttons.saveChanges": "മാറ്റങ്ങൾ സൂക്ഷിക്കുക",
"globals.days.0": "ഞായർ",
"globals.days.1": "തിങ്കൾ", "globals.days.1": "തിങ്കൾ",
"globals.days.2": "ചൊവ്വ", "globals.days.2": "ചൊവ്വ",
"globals.days.3": "ബുധൻ", "globals.days.3": "ബുധൻ",
"globals.days.4": "വ്യാഴം", "globals.days.4": "വ്യാഴം",
"globals.days.5": "വെള്ളി", "globals.days.5": "വെള്ളി",
"globals.days.6": "ശനി", "globals.days.6": "ശനി",
"globals.days.7": "ഞായർ",
"globals.fields.createdAt": "നിർമ്മിച്ചത്", "globals.fields.createdAt": "നിർമ്മിച്ചത്",
"globals.fields.id": "ഐഡി", "globals.fields.id": "ഐഡി",
"globals.fields.name": "പേര്", "globals.fields.name": "പേര്",
@ -242,6 +247,8 @@
"public.errorTitle": "എറർ", "public.errorTitle": "എറർ",
"public.invalidFeature": "ഈ ഫീച്ചർ ലഭ്യമല്ല", "public.invalidFeature": "ഈ ഫീച്ചർ ലഭ്യമല്ല",
"public.invalidLink": "കണ്ണി അസാധുവാണ്", "public.invalidLink": "കണ്ണി അസാധുവാണ്",
"public.noListsAvailable": "No lists available to subscribe.",
"public.noListsSelected": "No valid lists selected to subscribe.",
"public.noSubInfo": "സ്ഥിരീകരിക്കാനായി വരിക്കാരനാകാനുള്ള അഭ്യർത്ഥനകളൊന്നുമില്ല", "public.noSubInfo": "സ്ഥിരീകരിക്കാനായി വരിക്കാരനാകാനുള്ള അഭ്യർത്ഥനകളൊന്നുമില്ല",
"public.noSubTitle": "വരിക്കാരാരുമില്ല", "public.noSubTitle": "വരിക്കാരാരുമില്ല",
"public.notFoundTitle": "കണ്ടെത്തിയില്ല", "public.notFoundTitle": "കണ്ടെത്തിയില്ല",
@ -251,10 +258,14 @@
"public.privacyTitle": "സ്വകാര്യതയും വിവരങ്ങളും", "public.privacyTitle": "സ്വകാര്യതയും വിവരങ്ങളും",
"public.privacyWipe": "നിങ്ങളുടെ വിവരങ്ങൾ എന്നന്നേയ്ക്കുമായി ഇല്ലാതാക്കുക", "public.privacyWipe": "നിങ്ങളുടെ വിവരങ്ങൾ എന്നന്നേയ്ക്കുമായി ഇല്ലാതാക്കുക",
"public.privacyWipeHelp": "താങ്കൾ വരിക്കാരനായിരിക്കുന്നതും അനുബന്ധ വിവരങ്ങളും ഡേറ്റാബേസിൽ നിന്നും എന്നത്തേയ്ക്കുമായി നീക്കം ചെയ്യുക.", "public.privacyWipeHelp": "താങ്കൾ വരിക്കാരനായിരിക്കുന്നതും അനുബന്ധ വിവരങ്ങളും ഡേറ്റാബേസിൽ നിന്നും എന്നത്തേയ്ക്കുമായി നീക്കം ചെയ്യുക.",
"public.sub": "Subscribe",
"public.subConfirmed": "വരിക്കാരനായി", "public.subConfirmed": "വരിക്കാരനായി",
"public.subConfirmedTitle": "സ്ഥിരീകരിച്ചു", "public.subConfirmedTitle": "സ്ഥിരീകരിച്ചു",
"public.subName": "Name (optional)",
"public.subNotFound": "വരിക്കാരനെ കണ്ടത്തിയില്ല.", "public.subNotFound": "വരിക്കാരനെ കണ്ടത്തിയില്ല.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subPrivateList": "സ്വകാര്യ ലിസ്റ്റ്", "public.subPrivateList": "സ്വകാര്യ ലിസ്റ്റ്",
"public.subTitle": "Subscribe",
"public.unsub": "വരിക്കാരനല്ലാതാകുക", "public.unsub": "വരിക്കാരനല്ലാതാകുക",
"public.unsubFull": "ഭാവിയിലുള്ള ഇ-മെയിലുകളിൽനിന്നും ഒഴിവാകുക.", "public.unsubFull": "ഭാവിയിലുള്ള ഇ-മെയിലുകളിൽനിന്നും ഒഴിവാകുക.",
"public.unsubHelp": "ഇനിമേൽ ഈ ലിസ്റ്റിന്റെ വരിക്കാരനാകേണ്ട എന്നുറപ്പാണോ?", "public.unsubHelp": "ഇനിമേൽ ഈ ലിസ്റ്റിന്റെ വരിക്കാരനാകേണ്ട എന്നുറപ്പാണോ?",
@ -262,11 +273,14 @@
"public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി", "public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി",
"public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക", "public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക",
"public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക", "public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.duplicateMessengerName": "ഒരേ പേരിൽ ഒന്നിലധികം സന്ദശവാഹകർ: {name}", "settings.duplicateMessengerName": "ഒരേ പേരിൽ ഒന്നിലധികം സന്ദശവാഹകർ: {name}",
"settings.errorEncoding": "ക്രമീകരണം എൻകോഡ് ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}", "settings.errorEncoding": "ക്രമീകരണം എൻകോഡ് ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}",
"settings.errorNoSMTP": "കുറഞ്ഞപക്ഷം ഒരു എസ്. എം. ടീ. പീ ബ്ലൊക്കെങ്കിലും പ്രവർത്തനക്ഷമയിരിക്കണം", "settings.errorNoSMTP": "കുറഞ്ഞപക്ഷം ഒരു എസ്. എം. ടീ. പീ ബ്ലൊക്കെങ്കിലും പ്രവർത്തനക്ഷമയിരിക്കണം",
"settings.general.adminNotifEmails": "കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പ് ഇ-മെയിലുകൾ", "settings.general.adminNotifEmails": "കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പ് ഇ-മെയിലുകൾ",
"settings.general.adminNotifEmailsHelp": "ഇംപോർട്ട് ചെയ്തതിലുള്ള വിവരങ്ങൾ, ക്യാമ്പേയ്ൻ പൂർത്തീകരണം, പ്രശ്നങ്ങൾ എന്നിങ്ങനെയുള്ള പ്രധാനപ്പെട്ട കാര്യനിര്‍വ്വാഹകർക്കുള്ള അറിയിപ്പിനായുള്ള കോമാ ഉപയോഗിച്ച് വേർതിരിച്ച ഇ-മെയിൽ വിലാസങ്ങൾ.", "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.faviconURL": "ഫാവ് ഐക്കൺ യൂ. ആർ. എൽ",
"settings.general.faviconURLHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ഫാവ് ഐക്കണിന്റെ പൂർണ്ണ വെബ് വിലാസം.", "settings.general.faviconURLHelp": "(ഐച്ഛികം) വരിക്കാരനല്ലാതാകാനുള്ള പേജുപോലുള്ള പൊതുവായ പേജുകളിൽ കാണിക്കുന്നതിനുവേണ്ടിയുള്ള ഫാവ് ഐക്കണിന്റെ പൂർണ്ണ വെബ് വിലാസം.",
"settings.general.fromEmail": "സ്ഥിരസ്ഥിതി `from` ഇ-മെയിൽ", "settings.general.fromEmail": "സ്ഥിരസ്ഥിതി `from` ഇ-മെയിൽ",
@ -286,7 +300,7 @@
"settings.media.s3.bucketTypePrivate": "സ്വകാര്യമായ", "settings.media.s3.bucketTypePrivate": "സ്വകാര്യമായ",
"settings.media.s3.bucketTypePublic": "പൊതുവായ", "settings.media.s3.bucketTypePublic": "പൊതുവായ",
"settings.media.s3.key": "AWS പ്രവേശന വാക്യം", "settings.media.s3.key": "AWS പ്രവേശന വാക്യം",
"settings.media.s3.region": "മേല", "settings.media.s3.region": "മേല",
"settings.media.s3.secret": "AWS പ്രവേശന രഹസ്യം", "settings.media.s3.secret": "AWS പ്രവേശന രഹസ്യം",
"settings.media.s3.uploadExpiry": "അപ്ലോഡിന്റെ കാലാവധി", "settings.media.s3.uploadExpiry": "അപ്ലോഡിന്റെ കാലാവധി",
"settings.media.s3.uploadExpiryHelp": "(ഐച്ഛികം) മുൻകൂട്ടി നിർമ്മിക്കുന്ന യൂ. ആർ. എല്ലിനുള്ള സെക്കന്റിലുള്ള TTL വ്യക്തമാക്കുക . സ്വകാര്യ ബക്കറ്റുകൾക്ക് മാത്രമേ ബാധകമാകൂ (s, m, h, d എന്നിവ യഥാക്രമം സെക്കന്റ്, മിനുട്ട്, മണിക്കൂർ, ദിവസങ്ങൾ എന്നിവയെ സൂചിപ്പിക്കുന്നു).", "settings.media.s3.uploadExpiryHelp": "(ഐച്ഛികം) മുൻകൂട്ടി നിർമ്മിക്കുന്ന യൂ. ആർ. എല്ലിനുള്ള സെക്കന്റിലുള്ള TTL വ്യക്തമാക്കുക . സ്വകാര്യ ബക്കറ്റുകൾക്ക് മാത്രമേ ബാധകമാകൂ (s, m, h, d എന്നിവ യഥാക്രമം സെക്കന്റ്, മിനുട്ട്, മണിക്കൂർ, ദിവസങ്ങൾ എന്നിവയെ സൂചിപ്പിക്കുന്നു).",
@ -299,7 +313,7 @@
"settings.messengers.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.", "settings.messengers.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
"settings.messengers.messageDiscard": "മാറ്റങ്ങൾ നിരസിക്കട്ടെ?", "settings.messengers.messageDiscard": "മാറ്റങ്ങൾ നിരസിക്കട്ടെ?",
"settings.messengers.messageSaved": "ക്രമീകരണങ്ങൾ സംരക്ഷിച്ചു. ആപ്പ് പുനരാരംഭിക്കുന്നു ...", "settings.messengers.messageSaved": "ക്രമീകരണങ്ങൾ സംരക്ഷിച്ചു. ആപ്പ് പുനരാരംഭിക്കുന്നു ...",
"settings.messengers.name": "സന്േശ വാഹകർ", "settings.messengers.name": "സന്േശ വാഹകർ",
"settings.messengers.nameHelp": "ഉദാഹരണം: എന്റെ-ലിസ്റ്റ്. അക്കങ്ങളും അക്ഷരങ്ങളും / ഡാഷും.", "settings.messengers.nameHelp": "ഉദാഹരണം: എന്റെ-ലിസ്റ്റ്. അക്കങ്ങളും അക്ഷരങ്ങളും / ഡാഷും.",
"settings.messengers.password": "രഹസ്യ വാക്ക്", "settings.messengers.password": "രഹസ്യ വാക്ക്",
"settings.messengers.retries": "പുനഃശ്രമങ്ങൾ", "settings.messengers.retries": "പുനഃശ്രമങ്ങൾ",
@ -310,6 +324,7 @@
"settings.messengers.url": "യൂ. ആർ. എൽ", "settings.messengers.url": "യൂ. ആർ. എൽ",
"settings.messengers.urlHelp": "പോസ്റ്റ്ബാക്ക് സേർവറിന്റെ റൂട്ട് യൂ. ആർ. എൽ.", "settings.messengers.urlHelp": "പോസ്റ്റ്ബാക്ക് സേർവറിന്റെ റൂട്ട് യൂ. ആർ. എൽ.",
"settings.messengers.username": "ഉപഭോക്ത്ര നാമം", "settings.messengers.username": "ഉപഭോക്ത്ര നാമം",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.performance.batchSize": "ബാച്ചിന്റെ വലിപ്പം", "settings.performance.batchSize": "ബാച്ചിന്റെ വലിപ്പം",
"settings.performance.batchSizeHelp": "ഒരാവർത്തനത്തിൽ എത്ര വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കണം. ഓരോ തവണയും വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കുകയും അടുത്ത ആവർത്തനത്തിൽ അടുത്ത ബാച്ചിനെ എടുക്കുകയും അങ്ങനെ തുടരുകയും ചെയ്യും. ഈ മൂല്യം പരമാവധി ത്രൂപുട്ടിനേക്കാളും (concurrency * message_rate) കൂടുതലാകുന്നതാണ് നല്ലത്.", "settings.performance.batchSizeHelp": "ഒരാവർത്തനത്തിൽ എത്ര വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കണം. ഓരോ തവണയും വരിക്കാരെ ഡാറ്റാബേസിൽ നിന്നും എടുക്കുകയും അടുത്ത ആവർത്തനത്തിൽ അടുത്ത ബാച്ചിനെ എടുക്കുകയും അങ്ങനെ തുടരുകയും ചെയ്യും. ഈ മൂല്യം പരമാവധി ത്രൂപുട്ടിനേക്കാളും (concurrency * message_rate) കൂടുതലാകുന്നതാണ് നല്ലത്.",
"settings.performance.concurrency": "കൺകറൻസി", "settings.performance.concurrency": "കൺകറൻസി",
@ -336,6 +351,7 @@
"settings.privacy.listUnsubHeader": "`List-Unsubscribe` തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക", "settings.privacy.listUnsubHeader": "`List-Unsubscribe` തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക",
"settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.", "settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.",
"settings.privacy.name": "സ്വകാര്യത", "settings.privacy.name": "സ്വകാര്യത",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ", "settings.smtp.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ",
"settings.smtp.customHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ", "settings.smtp.customHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ",
"settings.smtp.customHeadersHelp": "ഈ സേർവറിൽ നിന്നും അയക്കുന്ന എല്ലാ ഈ-മെയിലിലും ഉണ്ടാകേണ്ട ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ. ഉദാഹരണം: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.customHeadersHelp": "ഈ സേർവറിൽ നിന്നും അയക്കുന്ന എല്ലാ ഈ-മെയിലിലും ഉണ്ടാകേണ്ട ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ. ഉദാഹരണം: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
@ -364,6 +380,7 @@
"settings.smtp.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി", "settings.smtp.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി",
"settings.smtp.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).", "settings.smtp.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
"settings.title": "ക്രമീകരണങ്ങൾ", "settings.title": "ക്രമീകരണങ്ങൾ",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "വിപുലമായത്", "subscribers.advancedQuery": "വിപുലമായത്",
"subscribers.advancedQueryHelp": "വരിക്കാരുടെ വിവരങ്ങൾ മനസിലാക്കുന്നതിനായുള്ള ഭാഗികമായ SQL പ്രയേഗം", "subscribers.advancedQueryHelp": "വരിക്കാരുടെ വിവരങ്ങൾ മനസിലാക്കുന്നതിനായുള്ള ഭാഗികമായ SQL പ്രയേഗം",
"subscribers.attribs": "ആട്രിബ്യൂട്ടുകൾ", "subscribers.attribs": "ആട്രിബ്യൂട്ടുകൾ",
@ -400,6 +417,7 @@
"subscribers.reset": "പുനഃസജ്ജമാക്കുക", "subscribers.reset": "പുനഃസജ്ജമാക്കുക",
"subscribers.selectAll": "{num} എല്ലാം തിരഞ്ഞടുക്കുക", "subscribers.selectAll": "{num} എല്ലാം തിരഞ്ഞടുക്കുക",
"subscribers.status.blocklisted": "തടയുന്ന പട്ടികയിൽ ചേർത്തു", "subscribers.status.blocklisted": "തടയുന്ന പട്ടികയിൽ ചേർത്തു",
"subscribers.status.confirmed": "Confirmed",
"subscribers.status.enabled": "പ്രവർത്തനക്ഷമാക്കി", "subscribers.status.enabled": "പ്രവർത്തനക്ഷമാക്കി",
"subscribers.status.subscribed": "വരിക്കാരനായി", "subscribers.status.subscribed": "വരിക്കാരനായി",
"subscribers.status.unconfirmed": "തീർച്ചപ്പെടുത്താത്തത്", "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.fromAddress": "Endereço do Remetente",
"campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>", "campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
"campaigns.invalid": "Campanha inválida", "campaigns.invalid": "Campanha inválida",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.", "campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
"campaigns.newCampaign": "Nova campanha", "campaigns.newCampaign": "Nova campanha",
"campaigns.noKnownSubsToTest": "Não existem subscritores para testar.", "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.confirmSubHelp": "Confirme a sua subscrição clicando no botão abaixo.",
"email.optin.confirmSubInfo": "Foi adicionado às seguintes listas:", "email.optin.confirmSubInfo": "Foi adicionado às seguintes listas:",
"email.optin.confirmSubTitle": "Confirmar subscrição", "email.optin.confirmSubTitle": "Confirmar subscrição",
"email.optin.confirmSubWelcome": "Olá {name},", "email.optin.confirmSubWelcome": "Olá",
"email.optin.privateList": "Lista privada", "email.optin.privateList": "Lista privada",
"email.status.campaignReason": "Motivo", "email.status.campaignReason": "Motivo",
"email.status.campaignSent": "Enviada", "email.status.campaignSent": "Enviada",
@ -92,6 +93,7 @@
"email.unsubHelp": "Não quer receber estes e-mails?", "email.unsubHelp": "Não quer receber estes e-mails?",
"forms.formHTML": "Formulário HTML", "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.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.publicLists": "Listas públicas",
"forms.publicSubPage": "Página pública de subscrição", "forms.publicSubPage": "Página pública de subscrição",
"forms.selectHelp": "Seleciona listas para adicionar ao formulário.", "forms.selectHelp": "Seleciona listas para adicionar ao formulário.",
@ -111,13 +113,13 @@
"globals.buttons.remove": "Remover", "globals.buttons.remove": "Remover",
"globals.buttons.save": "Guardar", "globals.buttons.save": "Guardar",
"globals.buttons.saveChanges": "Guardar alterações", "globals.buttons.saveChanges": "Guardar alterações",
"globals.days.0": "Dom",
"globals.days.1": "Seg", "globals.days.1": "Seg",
"globals.days.2": "Ter", "globals.days.2": "Ter",
"globals.days.3": "Qua", "globals.days.3": "Qua",
"globals.days.4": "Qui", "globals.days.4": "Qui",
"globals.days.5": "Sex", "globals.days.5": "Sex",
"globals.days.6": "Sáb", "globals.days.6": "Sáb",
"globals.days.7": "Dom",
"globals.fields.createdAt": "Criado a", "globals.fields.createdAt": "Criado a",
"globals.fields.id": "ID", "globals.fields.id": "ID",
"globals.fields.name": "Nome", "globals.fields.name": "Nome",
@ -261,6 +263,7 @@
"public.subConfirmedTitle": "Confirmado", "public.subConfirmedTitle": "Confirmado",
"public.subName": "Nome (opcional)", "public.subName": "Nome (opcional)",
"public.subNotFound": "Subscrição não encontrada.", "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.subPrivateList": "Lista privada",
"public.subTitle": "Subscrever", "public.subTitle": "Subscrever",
"public.unsub": "Cancelar subscrição", "public.unsub": "Cancelar subscrição",
@ -270,6 +273,7 @@
"public.unsubbedInfo": "A sua subscrição foi cancelada com sucesso.", "public.unsubbedInfo": "A sua subscrição foi cancelada com sucesso.",
"public.unsubbedTitle": "Subscrição cancelada", "public.unsubbedTitle": "Subscrição cancelada",
"public.unsubscribeTitle": "Cancelar subscrição da lista de emails", "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.duplicateMessengerName": "Nome duplicado do mensageiro: {name}",
"settings.errorEncoding": "Erro de definições de codificação: {error}", "settings.errorEncoding": "Erro de definições de codificação: {error}",
"settings.errorNoSMTP": "Pelo menos um bloco SMTP deve estar ativo", "settings.errorNoSMTP": "Pelo menos um bloco SMTP deve estar ativo",
@ -320,6 +324,7 @@
"settings.messengers.url": "URL", "settings.messengers.url": "URL",
"settings.messengers.urlHelp": "URL base do servidor Postback.", "settings.messengers.urlHelp": "URL base do servidor Postback.",
"settings.messengers.username": "Nome de utilizador", "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.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.batchSizeHelp": "O número de subscritores para ir buscar à base de dados numa só iteração. Cada iteração vai buscar subscritores à base de dados, envia-lhe mensagens, e depois segue para a nova iteração para ir buscar o lote seguinte. Isto deve idealmente ser maior do que a máxima taxa de transferência alcançável (simultaneidade * taxa de mensagens).",
"settings.performance.concurrency": "Simultaneidade", "settings.performance.concurrency": "Simultaneidade",
@ -346,6 +351,7 @@
"settings.privacy.listUnsubHeader": "Incluir header `List-Unsubscribe`", "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.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.privacy.name": "Privacidade",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "Protocolo Autenticação", "settings.smtp.authProtocol": "Protocolo Autenticação",
"settings.smtp.customHeaders": "Headers customizados", "settings.smtp.customHeaders": "Headers customizados",
"settings.smtp.customHeadersHelp": "Array opcional de headers de email a incluir em todas as mensagens enviadas deste servidor. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.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.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.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.title": "Definições",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Avançado", "subscribers.advancedQuery": "Avançado",
"subscribers.advancedQueryHelp": "Expressão SQL parcial para consultar atributos de subscritores", "subscribers.advancedQueryHelp": "Expressão SQL parcial para consultar atributos de subscritores",
"subscribers.attribs": "Atributos", "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" "sync"
"time" "time"
"github.com/Masterminds/sprig/v3"
"github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
@ -315,7 +316,7 @@ func (m *Manager) messageWorker() {
// TemplateFuncs returns the template functions to be applied into // TemplateFuncs returns the template functions to be applied into
// compiled campaign templates. // compiled campaign templates.
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap { func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
return template.FuncMap{ f := template.FuncMap{
"TrackLink": func(url string, msg *CampaignMessage) string { "TrackLink": func(url string, msg *CampaignMessage) string {
subUUID := msg.Subscriber.UUID subUUID := msg.Subscriber.UUID
if !m.cfg.IndividualTracking { if !m.cfg.IndividualTracking {
@ -353,7 +354,14 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
"L": func() *i18n.I18n { "L": func() *i18n.I18n {
return m.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. // 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. // for campaigns to process and dispatches them to the manager.
func (m *Manager) scanCampaigns(tick time.Duration) { func (m *Manager) scanCampaigns(tick time.Duration) {
t := time.NewTicker(tick) t := time.NewTicker(tick)
defer t.Stop()
for { for {
select { select {
// Periodically scan the data source for campaigns to process. // 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 package models
import ( import (
"bytes"
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "errors"
@ -12,6 +13,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types" "github.com/jmoiron/sqlx/types"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/yuin/goldmark"
null "gopkg.in/volatiletech/null.v6" null "gopkg.in/volatiletech/null.v6"
) )
@ -39,6 +41,7 @@ const (
CampaignTypeOptin = "optin" CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext" CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html" CampaignContentTypeHTML = "html"
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain" CampaignContentTypePlain = "plain"
// List. // List.
@ -312,8 +315,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
return fmt.Errorf("error compiling base template: %v", err) return fmt.Errorf("error compiling base template: %v", err)
} }
// 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. // Compile the campaign message.
body = c.Body
for _, r := range regTplFuncs { for _, r := range regTplFuncs {
body = r.regExp.ReplaceAllString(body, r.replace) body = r.regExp.ReplaceAllString(body, r.replace)
} }

View File

@ -1,8 +1,13 @@
-- subscribers -- subscribers
-- name: get-subscriber -- name: get-subscriber
-- Get a single subscriber by id or UUID. -- Get a single subscriber by id or UUID or email.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; 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 -- name: subscriber-exists
-- Check if a subscriber exists by id or UUID. -- 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), email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
name=(CASE WHEN $3 != '' THEN $3 ELSE name END), name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
status=(CASE WHEN $4 != '' THEN $4::subscriber_status ELSE status 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() updated_at=NOW()
WHERE id = $1 RETURNING id 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 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_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 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 -- subscribers
DROP TABLE IF EXISTS subscribers CASCADE; 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> <h2>{{ L.Ts "email.status.campaignUpdateTitle" }}</h2>
<table width="100%"> <table width="100%">
<tr> <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> <td><a href="{{ index . "RootURL" }}/campaigns/{{ index . "ID" }}">{{ index . "Name" }}</a></td>
</tr> </tr>
<tr> <tr>
@ -22,4 +22,4 @@
{{ end }} {{ end }}
</table> </table>
{{ template "footer" }} {{ template "footer" }}
{{ end }} {{ end }}

View File

@ -1,6 +1,6 @@
{{ define "optin-campaign" }} {{ 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> <p>{{ L.Ts "email.optin.confirmSubInfo" }}</p>
<ul> <ul>
{{ range $i, $l := .Lists }} {{ range $i, $l := .Lists }}

View File

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