Merge branch 'bounce'
This commit is contained in:
commit
1be8c7d387
49 changed files with 2890 additions and 309 deletions
251
cmd/bounce.go
Normal file
251
cmd/bounce.go
Normal file
|
@ -0,0 +1,251 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/internal/subimporter"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type bouncesWrap struct {
|
||||
Results []models.Bounce `json:"results"`
|
||||
|
||||
Total int `json:"total"`
|
||||
PerPage int `json:"per_page"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// handleGetBounces handles retrieval of bounce records.
|
||||
func handleGetBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = getPagination(c.QueryParams(), 50)
|
||||
out bouncesWrap
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
|
||||
source = c.FormValue("source")
|
||||
orderBy = c.FormValue("order_by")
|
||||
order = c.FormValue("order")
|
||||
)
|
||||
|
||||
// Fetch one list.
|
||||
single := false
|
||||
if id > 0 {
|
||||
single = true
|
||||
}
|
||||
|
||||
// Sort params.
|
||||
if !strSliceContains(orderBy, bounceQuerySortFields) {
|
||||
orderBy = "created_at"
|
||||
}
|
||||
if order != sortAsc && order != sortDesc {
|
||||
order = sortDesc
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order)
|
||||
if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil {
|
||||
app.log.Printf("error fetching bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out.Results) == 0 {
|
||||
out.Results = []models.Bounce{}
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
if single {
|
||||
return c.JSON(http.StatusOK, okResp{out.Results[0]})
|
||||
}
|
||||
|
||||
// Meta.
|
||||
out.Total = out.Results[0].Total
|
||||
out.Page = pg.Page
|
||||
out.PerPage = pg.PerPage
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetSubscriberBounces retrieves a subscriber's bounce records.
|
||||
func handleGetSubscriberBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
subID = c.Param("id")
|
||||
)
|
||||
|
||||
id, _ := strconv.ParseInt(subID, 10, 64)
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
out := []models.Bounce{}
|
||||
stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC")
|
||||
if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil {
|
||||
app.log.Printf("error fetching bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleDeleteBounces handles bounce deletion, either a single one (ID in the URI), or a list.
|
||||
func handleDeleteBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
all, _ = strconv.ParseBool(c.QueryParam("all"))
|
||||
IDs = pq.Int64Array{}
|
||||
)
|
||||
|
||||
// Is it an /:id call?
|
||||
if pID != "" {
|
||||
id, _ := strconv.ParseInt(pID, 10, 64)
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
IDs = append(IDs, id)
|
||||
} else if !all {
|
||||
// Multiple IDs.
|
||||
i, err := parseStringIDs(c.Request().URL.Query()["id"])
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidID", "error", err.Error()))
|
||||
}
|
||||
|
||||
if len(i) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidID"))
|
||||
}
|
||||
IDs = i
|
||||
}
|
||||
|
||||
if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil {
|
||||
app.log.Printf("error deleting bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorDeleting",
|
||||
"name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleBounceWebhook renders the HTML preview of a template.
|
||||
func handleBounceWebhook(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
service = c.Param("service")
|
||||
|
||||
bounces []models.Bounce
|
||||
)
|
||||
|
||||
// Read the request body instead of using using c.Bind() to read to save the entire raw request as meta.
|
||||
rawReq, err := ioutil.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
app.log.Printf("error reading ses notification body: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
switch true {
|
||||
// Native internal webhook.
|
||||
case service == "":
|
||||
var b models.Bounce
|
||||
if err := json.Unmarshal(rawReq, &b); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if err := validateBounceFields(b, app); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.Email = strings.ToLower(b.Email)
|
||||
|
||||
if len(b.Meta) == 0 {
|
||||
b.Meta = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
if b.CreatedAt.Year() == 0 {
|
||||
b.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
bounces = append(bounces, b)
|
||||
|
||||
// Amazon SES.
|
||||
case service == "ses" && app.constants.BounceSESEnabled:
|
||||
switch c.Request().Header.Get("X-Amz-Sns-Message-Type") {
|
||||
// SNS webhook registration confirmation. Only after these are processed will the endpoint
|
||||
// start getting bounce notifications.
|
||||
case "SubscriptionConfirmation", "UnsubscribeConfirmation":
|
||||
if err := app.bounce.SES.ProcessSubscription(rawReq); err != nil {
|
||||
app.log.Printf("error processing SNS (SES) subscription: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
break
|
||||
|
||||
// Bounce notification.
|
||||
case "Notification":
|
||||
b, err := app.bounce.SES.ProcessBounce(rawReq)
|
||||
if err != nil {
|
||||
app.log.Printf("error processing SES notification: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
bounces = append(bounces, b)
|
||||
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
// SendGrid.
|
||||
case service == "sendgrid" && app.constants.BounceSendgridEnabled:
|
||||
var (
|
||||
sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature")
|
||||
ts = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp")
|
||||
)
|
||||
|
||||
// Sendgrid sends multiple bounces.
|
||||
bs, err := app.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
|
||||
if err != nil {
|
||||
app.log.Printf("error processing sendgrid notification: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
bounces = append(bounces, bs...)
|
||||
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService"))
|
||||
}
|
||||
|
||||
// Record bounces if any.
|
||||
for _, b := range bounces {
|
||||
if err := app.bounce.Record(b); err != nil {
|
||||
app.log.Printf("error recording bounce: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validateBounceFields(b models.Bounce, app *App) error {
|
||||
if b.Email == "" && b.SubscriberUUID == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if b.Email != "" && !subimporter.IsEmail(b.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidEmail"))
|
||||
}
|
||||
|
||||
if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -73,6 +73,7 @@ var (
|
|||
regexFullTextQuery = regexp.MustCompile(`\s+`)
|
||||
|
||||
campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
|
||||
bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"}
|
||||
)
|
||||
|
||||
// handleGetCampaigns handles retrieval of campaigns.
|
||||
|
|
|
@ -62,6 +62,8 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
|
|||
|
||||
g.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces)
|
||||
g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces)
|
||||
g.POST("/api/subscribers", handleCreateSubscriber)
|
||||
g.PUT("/api/subscribers/:id", handleUpdateSubscriber)
|
||||
g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
|
||||
|
@ -72,6 +74,10 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
|
||||
g.DELETE("/api/subscribers", handleDeleteSubscribers)
|
||||
|
||||
g.GET("/api/bounces", handleGetBounces)
|
||||
g.DELETE("/api/bounces", handleDeleteBounces)
|
||||
g.DELETE("/api/bounces/:id", handleDeleteBounces)
|
||||
|
||||
// Subscriber operations based on arbitrary SQL queries.
|
||||
// These aren't very REST-like.
|
||||
g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
|
||||
|
@ -132,6 +138,14 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.GET("/settings", handleIndexPage)
|
||||
g.GET("/settings/logs", handleIndexPage)
|
||||
|
||||
if app.constants.BounceWebhooksEnabled {
|
||||
// Private authenticated bounce endpoint.
|
||||
g.POST("/webhooks/bounce", handleBounceWebhook)
|
||||
|
||||
// Public bounce endpoints for webservices like SES.
|
||||
e.POST("/webhooks/service/:service", handleBounceWebhook)
|
||||
}
|
||||
|
||||
// Public subscriber facing views.
|
||||
e.GET("/subscription/form", handleSubscriptionFormPage)
|
||||
e.POST("/subscription/form", handleSubscriptionForm)
|
||||
|
|
52
cmd/init.go
52
cmd/init.go
|
@ -21,6 +21,8 @@ import (
|
|||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/bounce/mailbox"
|
||||
"github.com/knadh/listmonk/internal/i18n"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/internal/media"
|
||||
|
@ -65,6 +67,10 @@ type constants struct {
|
|||
OptinURL string
|
||||
MessageURL string
|
||||
MediaProvider string
|
||||
|
||||
BounceWebhooksEnabled bool
|
||||
BounceSESEnabled bool
|
||||
BounceSendgridEnabled bool
|
||||
}
|
||||
|
||||
func initFlags() {
|
||||
|
@ -296,6 +302,10 @@ func initConstants() *constants {
|
|||
|
||||
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
|
||||
c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
|
||||
|
||||
c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled")
|
||||
c.BounceSESEnabled = ko.Bool("bounce.ses_enabled")
|
||||
c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
|
||||
return &c
|
||||
}
|
||||
|
||||
|
@ -344,8 +354,7 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
|
|||
SlidingWindow: ko.Bool("app.message_sliding_window"),
|
||||
SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
|
||||
SlidingWindowRate: ko.Int("app.message_sliding_window_rate"),
|
||||
}, newManagerDB(q), campNotifCB, app.i18n, lo)
|
||||
|
||||
}, newManagerStore(q), campNotifCB, app.i18n, lo)
|
||||
}
|
||||
|
||||
// initImporter initializes the bulk subscriber importer.
|
||||
|
@ -495,6 +504,45 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *c
|
|||
return tpl
|
||||
}
|
||||
|
||||
// initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks
|
||||
// for incoming bounce events.
|
||||
func initBounceManager(app *App) *bounce.Manager {
|
||||
opt := bounce.Opt{
|
||||
BounceCount: ko.MustInt("bounce.count"),
|
||||
BounceAction: ko.MustString("bounce.action"),
|
||||
WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
|
||||
SESEnabled: ko.Bool("bounce.ses_enabled"),
|
||||
SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
|
||||
SendgridKey: ko.String("bounce.sendgrid_key"),
|
||||
}
|
||||
|
||||
// For now, only one mailbox is supported.
|
||||
for _, b := range ko.Slices("bounce.mailboxes") {
|
||||
if !b.Bool("enabled") {
|
||||
continue
|
||||
}
|
||||
|
||||
var boxOpt mailbox.Opt
|
||||
if err := b.UnmarshalWithConf("", &boxOpt, koanf.UnmarshalConf{Tag: "json"}); err != nil {
|
||||
lo.Fatalf("error reading bounce mailbox config: %v", err)
|
||||
}
|
||||
|
||||
opt.MailboxType = b.String("type")
|
||||
opt.MailboxEnabled = true
|
||||
opt.Mailbox = boxOpt
|
||||
break
|
||||
}
|
||||
|
||||
b, err := bounce.New(opt, &bounce.Queries{
|
||||
RecordQuery: app.queries.RecordBounce,
|
||||
}, app.log)
|
||||
if err != nil {
|
||||
lo.Fatalf("error initializing bounce manager: %v", err)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
|
||||
func initHTTPServer(app *App) *echo.Echo {
|
||||
// Initialize the HTTP server.
|
||||
|
|
|
@ -160,8 +160,7 @@ func handleUpdateList(c echo.Context) error {
|
|||
return handleGetLists(c)
|
||||
}
|
||||
|
||||
// handleDeleteLists handles deletion deletion,
|
||||
// either a single one (ID in the URI), or a list.
|
||||
// handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list.
|
||||
func handleDeleteLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/buflog"
|
||||
"github.com/knadh/listmonk/internal/i18n"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
|
@ -42,6 +43,7 @@ type App struct {
|
|||
messengers map[string]messenger.Messenger
|
||||
media media.Store
|
||||
i18n *i18n.I18n
|
||||
bounce *bounce.Manager
|
||||
notifTpls *template.Template
|
||||
log *log.Logger
|
||||
bufLog *buflog.BufLog
|
||||
|
@ -168,6 +170,11 @@ func main() {
|
|||
app.importer = initImporter(app.queries, db, app)
|
||||
app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
|
||||
|
||||
if ko.Bool("bounce.enabled") {
|
||||
app.bounce = initBounceManager(app)
|
||||
go app.bounce.Run()
|
||||
}
|
||||
|
||||
// Initialize the default SMTP (`email`) messenger.
|
||||
app.messengers[emailMsgr] = initSMTPMessenger(app.manager)
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ type runnerDB struct {
|
|||
queries *Queries
|
||||
}
|
||||
|
||||
func newManagerDB(q *Queries) *runnerDB {
|
||||
func newManagerStore(q *Queries) *runnerDB {
|
||||
return &runnerDB{
|
||||
queries: q,
|
||||
}
|
||||
|
@ -64,3 +64,31 @@ func (r *runnerDB) CreateLink(url string) (string, error) {
|
|||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RecordBounce records a bounce event and returns the bounce count.
|
||||
func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) {
|
||||
var res = struct {
|
||||
SubscriberID int64 `db:"subscriber_id"`
|
||||
Num int `db:"num"`
|
||||
}{}
|
||||
|
||||
err := r.queries.UpdateCampaignStatus.Select(&res,
|
||||
b.SubscriberUUID,
|
||||
b.Email,
|
||||
b.CampaignUUID,
|
||||
b.Type,
|
||||
b.Source,
|
||||
b.Meta)
|
||||
|
||||
return res.SubscriberID, res.Num, err
|
||||
}
|
||||
|
||||
func (r *runnerDB) BlocklistSubscriber(id int64) error {
|
||||
_, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *runnerDB) DeleteSubscriber(id int64) error {
|
||||
_, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id})
|
||||
return err
|
||||
}
|
|
@ -83,6 +83,10 @@ type Queries struct {
|
|||
UpdateSettings *sqlx.Stmt `query:"update-settings"`
|
||||
|
||||
// GetStats *sqlx.Stmt `query:"get-stats"`
|
||||
RecordBounce *sqlx.Stmt `query:"record-bounce"`
|
||||
QueryBounces string `query:"query-bounces"`
|
||||
DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
|
||||
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
|
||||
}
|
||||
|
||||
// dbConf contains database config required for connecting to a DB.
|
||||
|
|
|
@ -80,6 +80,28 @@ type settings struct {
|
|||
Timeout string `json:"timeout"`
|
||||
MaxMsgRetries int `json:"max_msg_retries"`
|
||||
} `json:"messengers"`
|
||||
|
||||
BounceEnabled bool `json:"bounce.enabled"`
|
||||
BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"`
|
||||
BounceCount int `json:"bounce.count"`
|
||||
BounceAction string `json:"bounce.action"`
|
||||
SESEnabled bool `json:"bounce.ses_enabled"`
|
||||
SendgridEnabled bool `json:"bounce.sendgrid_enabled"`
|
||||
SendgridKey string `json:"bounce.sendgrid_key"`
|
||||
BounceBoxes []struct {
|
||||
UUID string `json:"uuid"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
ReturnPath string `json:"return_path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
ScanInterval string `json:"scan_interval"`
|
||||
} `json:"bounce.mailboxes"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -99,10 +121,14 @@ func handleGetSettings(c echo.Context) error {
|
|||
for i := 0; i < len(s.SMTP); i++ {
|
||||
s.SMTP[i].Password = ""
|
||||
}
|
||||
for i := 0; i < len(s.BounceBoxes); i++ {
|
||||
s.BounceBoxes[i].Password = ""
|
||||
}
|
||||
for i := 0; i < len(s.Messengers); i++ {
|
||||
s.Messengers[i].Password = ""
|
||||
}
|
||||
s.UploadS3AwsSecretAccessKey = ""
|
||||
s.SendgridKey = ""
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{s})
|
||||
}
|
||||
|
@ -154,6 +180,31 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
|
||||
}
|
||||
|
||||
// Bounce boxes.
|
||||
for i, s := range set.BounceBoxes {
|
||||
// Assign a UUID. The frontend only sends a password when the user explictly
|
||||
// changes the password. In other cases, the existing password in the DB
|
||||
// is copied while updating the settings and the UUID is used to match
|
||||
// the incoming array of blocks with the array in the DB.
|
||||
if s.UUID == "" {
|
||||
set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String()
|
||||
}
|
||||
|
||||
if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.bounces.invalidScanInterval"))
|
||||
}
|
||||
|
||||
// If there's no password coming in from the frontend, copy the existing
|
||||
// password by matching the UUID.
|
||||
if s.Password == "" {
|
||||
for _, c := range cur.BounceBoxes {
|
||||
if s.UUID == c.UUID {
|
||||
set.BounceBoxes[i].Password = c.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and sanitize postback Messenger names. Duplicates are disallowed
|
||||
// and "email" is a reserved name.
|
||||
names := map[string]bool{emailMsgr: true}
|
||||
|
@ -189,6 +240,9 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
if set.UploadS3AwsSecretAccessKey == "" {
|
||||
set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey
|
||||
}
|
||||
if set.SendgridKey == "" {
|
||||
set.SendgridKey = cur.SendgridKey
|
||||
}
|
||||
|
||||
// Marshal settings.
|
||||
b, err := json.Marshal(set)
|
||||
|
|
|
@ -614,6 +614,28 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleDeleteSubscriberBounces deletes all the bounces on a subscriber.
|
||||
func handleDeleteSubscriberBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
)
|
||||
|
||||
id, _ := strconv.ParseInt(pID, 10, 64)
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
if _, err := app.queries.DeleteBouncesBySubscriber.Exec(id, nil); err != nil {
|
||||
app.log.Printf("error deleting bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorDeleting",
|
||||
"name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleExportSubscriberData pulls the subscriber's profile,
|
||||
// list subscriptions, campaign views and clicks and produces
|
||||
// a JSON report. This is a privacy feature and depends on the
|
||||
|
|
|
@ -30,6 +30,7 @@ var migList = []migFunc{
|
|||
{"v0.8.0", migrations.V0_8_0},
|
||||
{"v0.9.0", migrations.V0_9_0},
|
||||
{"v1.0.0", migrations.V1_0_0},
|
||||
{"v2.0.0", migrations.V2_0_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
|
|
@ -510,6 +510,34 @@
|
|||
"magnify"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "e97fad4c93444c9b81151c2aa4086e13",
|
||||
"css": "chart-bar",
|
||||
"code": 59428,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M916 875H84V125H166V791H250V416H416V791H500V250H666V791H750V584H916V875Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"chart-bar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "61e03b48670cd93477e233e0d6bb3f1c",
|
||||
"css": "email-bounce",
|
||||
"code": 59429,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M916 834H750V959L541 771.5 750 584V709H916V834ZM834 166H166Q132.8 166 108.4 190.4T84 250V750Q84 785.2 108.4 809.6T166 834H459V750H166V334L500 541 834 334V625H916V250Q916 214.8 891.6 190.4T834 166ZM500 459L166 250H834Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"email-bounce"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
|
||||
"css": "vector-square",
|
||||
|
@ -4570,20 +4598,6 @@
|
|||
"chart-areaspline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "e97fad4c93444c9b81151c2aa4086e13",
|
||||
"css": "chart-bar",
|
||||
"code": 983336,
|
||||
"src": "custom_icons",
|
||||
"selected": false,
|
||||
"svg": {
|
||||
"path": "M916 875H84V125H166V791H250V416H416V791H500V250H666V791H750V584H916V875Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"chart-bar"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "298bc9b464d2b4e5cad91cd3d419747f",
|
||||
"css": "chart-histogram",
|
||||
|
@ -60444,20 +60458,6 @@
|
|||
"email-receive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "61e03b48670cd93477e233e0d6bb3f1c",
|
||||
"css": "email-receive-outline",
|
||||
"code": 987355,
|
||||
"src": "custom_icons",
|
||||
"selected": false,
|
||||
"svg": {
|
||||
"path": "M916 834H750V959L541 771.5 750 584V709H916V834ZM834 166H166Q132.8 166 108.4 190.4T84 250V750Q84 785.2 108.4 809.6T166 834H459V750H166V334L500 541 834 334V625H916V250Q916 214.8 891.6 190.4T834 166ZM500 459L166 250H834Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"email-receive-outline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "b52d1e4a907f8f98e038e8997079e456",
|
||||
"css": "email-send",
|
||||
|
|
|
@ -55,6 +55,10 @@
|
|||
<b-menu-item :to="{name: 'import'}" tag="router-link"
|
||||
:active="activeItem.import" data-cy="import"
|
||||
icon="file-upload-outline" :label="$t('menu.import')"></b-menu-item>
|
||||
|
||||
<b-menu-item :to="{name: 'bounces'}" tag="router-link"
|
||||
:active="activeItem.bounces" data-cy="bounces"
|
||||
icon="email-bounce" :label="$t('globals.terms.bounces')"></b-menu-item>
|
||||
</b-menu-item><!-- subscribers -->
|
||||
|
||||
<b-menu-item :expanded="activeGroup.campaigns"
|
||||
|
|
|
@ -111,6 +111,21 @@ export const deleteList = (id) => http.delete(`/api/lists/${id}`,
|
|||
export const getSubscribers = async (params) => http.get('/api/subscribers',
|
||||
{ params, loading: models.subscribers, store: models.subscribers });
|
||||
|
||||
export const getSubscriber = async (id) => http.get(`/api/subscribers/${id}`,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
export const getSubscriberBounces = async (id) => http.get(`/api/subscribers/${id}/bounces`,
|
||||
{ loading: models.bounces });
|
||||
|
||||
export const deleteSubscriberBounces = async (id) => http.delete(`/api/subscribers/${id}/bounces`,
|
||||
{ loading: models.bounces });
|
||||
|
||||
export const deleteBounce = async (id) => http.delete(`/api/bounces/${id}`,
|
||||
{ loading: models.bounces });
|
||||
|
||||
export const deleteBounces = async (params) => http.delete('/api/bounces',
|
||||
{ params, loading: models.bounces });
|
||||
|
||||
export const createSubscriber = (data) => http.post('/api/subscribers', data,
|
||||
{ loading: models.subscribers });
|
||||
|
||||
|
@ -148,6 +163,10 @@ export const getImportLogs = async () => http.get('/api/import/subscribers/logs'
|
|||
|
||||
export const stopImport = () => http.delete('/api/import/subscribers');
|
||||
|
||||
// Bounces.
|
||||
export const getBounces = async (params) => http.get('/api/bounces',
|
||||
{ params, loading: models.bounces });
|
||||
|
||||
// Campaigns.
|
||||
export const getCampaigns = async (params) => http.get('/api/campaigns',
|
||||
{ params, loading: models.campaigns, store: models.campaigns });
|
||||
|
|
18
frontend/src/assets/icons/fontello.css
vendored
18
frontend/src/assets/icons/fontello.css
vendored
|
@ -5,37 +5,37 @@
|
|||
font-style: normal;
|
||||
}
|
||||
|
||||
[class^="mdi-"]:before, [class*=" mdi-"]:before {
|
||||
[class^="mdi-"]:before, [class*=" mdi-"]:before {
|
||||
font-family: "fontello";
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: never;
|
||||
|
||||
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin-right: .2em;
|
||||
text-align: center;
|
||||
/* opacity: .8; */
|
||||
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
|
||||
|
||||
/* fix buttons height, for twitter bootstrap */
|
||||
line-height: 1em;
|
||||
|
||||
|
||||
/* Animation center compensation - margins should be symmetric */
|
||||
/* remove if not needed */
|
||||
margin-left: .2em;
|
||||
|
||||
|
||||
/* you can be more comfortable with increased icons size */
|
||||
/* font-size: 120%; */
|
||||
|
||||
|
||||
/* Font smoothing. That was taken from TWBS */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
|
||||
/* Uncomment for 3D effect */
|
||||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
@ -76,3 +76,5 @@
|
|||
.mdi-arrow-down:before { content: '\e821'; } /* '' */
|
||||
.mdi-cancel:before { content: '\e822'; } /* '' */
|
||||
.mdi-magnify:before { content: '\e823'; } /* '' */
|
||||
.mdi-chart-bar:before { content: '\e824'; } /* '' */
|
||||
.mdi-email-bounce:before { content: '\e825'; } /* '' */
|
||||
|
|
Binary file not shown.
|
@ -92,7 +92,17 @@ section {
|
|||
}
|
||||
|
||||
.box {
|
||||
box-shadow: 0 0 2px $grey-lighter;
|
||||
background: $white-bis;
|
||||
box-shadow: 2px 2px 5px $white-ter;
|
||||
border: 1px solid $grey-lightest;
|
||||
|
||||
hr {
|
||||
background-color: #efefef;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Two column sidebar+body layout */
|
||||
|
@ -323,7 +333,7 @@ section {
|
|||
|
||||
/* Tabs */
|
||||
.b-tabs .tab-content {
|
||||
padding-top: 2rem;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
@ -449,6 +459,12 @@ section.lists {
|
|||
}
|
||||
}
|
||||
|
||||
.bounces {
|
||||
pre {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import page */
|
||||
section.import {
|
||||
.delimiter input {
|
||||
|
|
|
@ -7,6 +7,7 @@ export const models = Object.freeze({
|
|||
campaigns: 'campaigns',
|
||||
templates: 'templates',
|
||||
media: 'media',
|
||||
bounces: 'bounces',
|
||||
settings: 'settings',
|
||||
logs: 'logs',
|
||||
});
|
||||
|
|
|
@ -35,6 +35,12 @@ const routes = [
|
|||
meta: { title: 'Import subscribers', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Import.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers/bounces',
|
||||
name: 'bounces',
|
||||
meta: { title: 'Bounces', group: 'subscribers' },
|
||||
component: () => import(/* webpackChunkName: "main" */ '../views/Bounces.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscribers/lists/:listID',
|
||||
name: 'subscribers_list',
|
||||
|
|
196
frontend/src/views/Bounces.vue
Normal file
196
frontend/src/views/Bounces.vue
Normal file
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<section class="bounces">
|
||||
<header class="page-header columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title is-4">{{ $t('globals.terms.bounces') }}
|
||||
<span v-if="bounces.total > 0">({{ bounces.total }})</span></h1>
|
||||
</div>
|
||||
<div class="column has-text-right buttons">
|
||||
<b-button v-if="bulk.checked.length > 0 || bulk.all" type="is-primary"
|
||||
icon-left="trash-can-outline" data-cy="btn-delete"
|
||||
@click.prevent="$utils.confirm(null, () => deleteBounces())">
|
||||
{{ $t('globals.buttons.delete') }}
|
||||
</b-button>
|
||||
<b-button v-if="bounces.total" icon-left="trash-can-outline" data-cy="btn-delete"
|
||||
@click.prevent="$utils.confirm(null, () => deleteBounces(true))">
|
||||
{{ $t('globals.buttons.deleteAll') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces"
|
||||
default-sort="createdAt"
|
||||
checkable
|
||||
@check-all="onTableCheck" @check="onTableCheck"
|
||||
:checked-rows.sync="bulk.checked"
|
||||
detailed
|
||||
show-detail-icon
|
||||
@details-open="(row) => $buefy.toast.open(`Expanded ${row.user.first_name}`)"
|
||||
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
|
||||
:current-page="queryParams.page" :per-page="bounces.perPage" :total="bounces.total"
|
||||
backend-sorting @sort="onSort">
|
||||
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')"
|
||||
:td-attrs="$utils.tdID" sortable>
|
||||
<router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId }}">
|
||||
{{ props.row.email }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="campaign_name" :label="$tc('globals.terms.campaign')"
|
||||
sortable>
|
||||
<router-link :to="{ name: 'bounces', query: { campaign_id: props.row.campaign.id }}">
|
||||
{{ props.row.campaign.name }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="source" :label="$t('bounces.source')" sortable>
|
||||
<router-link :to="{ name: 'bounces', query: { source: props.row.source } }">
|
||||
{{ props.row.source }}
|
||||
</router-link>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="created_at"
|
||||
:label="$t('globals.fields.createdAt')" sortable>
|
||||
{{ $utils.niceDate(props.row.createdAt, true) }}
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" cell-class="actions" align="right">
|
||||
<div>
|
||||
<a v-if="!props.row.isDefault" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => deleteBounce(props.row))"
|
||||
data-cy="btn-delete">
|
||||
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<span v-else class="a has-text-grey-light">
|
||||
<b-icon icon="trash-can-outline" size="is-small" />
|
||||
</span>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<template #detail="props">
|
||||
<pre class="is-size-7">{{ props.row.meta }}</pre>
|
||||
</template>
|
||||
|
||||
<template #empty v-if="!loading.templates">
|
||||
<empty-placeholder />
|
||||
</template>
|
||||
</b-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import { mapState } from 'vuex';
|
||||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
bounces: {},
|
||||
|
||||
// Table bulk row selection states.
|
||||
bulk: {
|
||||
checked: [],
|
||||
all: false,
|
||||
},
|
||||
|
||||
// Query params to filter the getSubscribers() API call.
|
||||
queryParams: {
|
||||
page: 1,
|
||||
orderBy: 'created_at',
|
||||
order: 'desc',
|
||||
campaignID: 0,
|
||||
source: '',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSort(field, direction) {
|
||||
this.queryParams.orderBy = field;
|
||||
this.queryParams.order = direction;
|
||||
this.getBounces();
|
||||
},
|
||||
|
||||
onPageChange(p) {
|
||||
this.queryParams.page = p;
|
||||
this.getBounces();
|
||||
},
|
||||
|
||||
onTableCheck() {
|
||||
// Disable bulk.all selection if there are no rows checked in the table.
|
||||
if (this.bulk.checked.length !== this.bounces.total) {
|
||||
this.bulk.all = false;
|
||||
}
|
||||
},
|
||||
|
||||
getBounces() {
|
||||
this.bulk.checked = [];
|
||||
this.bulk.all = false;
|
||||
|
||||
this.$api.getBounces({
|
||||
page: this.queryParams.page,
|
||||
order_by: this.queryParams.orderBy,
|
||||
order: this.queryParams.order,
|
||||
campaign_id: this.queryParams.campaign_id,
|
||||
source: this.queryParams.source,
|
||||
}).then((data) => {
|
||||
this.bounces = data;
|
||||
});
|
||||
},
|
||||
|
||||
deleteBounce(b) {
|
||||
this.$api.deleteBounce(b.id).then(() => {
|
||||
this.getBounces();
|
||||
this.$utils.toast(this.$t('globals.messages.deleted', { name: b.email }));
|
||||
});
|
||||
},
|
||||
|
||||
deleteBounces(all) {
|
||||
const fnSuccess = () => {
|
||||
this.getBounces();
|
||||
this.$utils.toast(this.$t('globals.messages.deletedCount',
|
||||
{ name: this.$tc('globals.terms.bounces'), num: this.bounces.total }));
|
||||
};
|
||||
|
||||
if (all) {
|
||||
this.$api.deleteBounces({ all: true }).then(fnSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = this.bulk.checked.map((s) => s.id);
|
||||
this.$api.deleteBounces({ id: ids }).then(fnSuccess);
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['templates', 'loading']),
|
||||
|
||||
selectedBounces() {
|
||||
if (this.bulk.all) {
|
||||
return this.bounces.total;
|
||||
}
|
||||
return this.bulk.checked.length;
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.$route.query.campaign_id) {
|
||||
this.queryParams.campaign_id = parseInt(this.$route.query.campaign_id, 10);
|
||||
}
|
||||
|
||||
if (this.$route.query.source) {
|
||||
this.queryParams.source = this.$route.query.source;
|
||||
}
|
||||
|
||||
this.getBounces();
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -117,6 +117,12 @@
|
|||
<label>{{ $t('campaigns.sent') }}</label>
|
||||
{{ stats.sent }} / {{ stats.toSend }}
|
||||
</p>
|
||||
<p>
|
||||
<label>{{ $t('globals.terms.bounces') }}</label>
|
||||
<router-link :to="{name: 'bounces', query: { campaign_id: props.row.id }}">
|
||||
{{ props.row.bounces }}
|
||||
</router-link>
|
||||
</p>
|
||||
<p title="Speed" v-if="stats.rate">
|
||||
<label><b-icon icon="speedometer" size="is-small"></b-icon></label>
|
||||
<span class="send-rate">
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<hr />
|
||||
|
||||
<section class="wrap-small">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" v-if="!isLoading">
|
||||
<b-tabs type="is-boxed" :animated="false">
|
||||
<b-tab-item :label="$t('settings.general.name')" label-position="on-border">
|
||||
<div class="items">
|
||||
|
@ -302,15 +302,15 @@
|
|||
<div class="column" :class="{'disabled': !item.enabled}">
|
||||
<div class="columns">
|
||||
<div class="column is-8">
|
||||
<b-field :label="$t('settings.smtp.host')" label-position="on-border"
|
||||
:message="$t('settings.smtp.hostHelp')">
|
||||
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
|
||||
:message="$t('settings.mailserver.hostHelp')">
|
||||
<b-input v-model="item.host" name="host"
|
||||
placeholder='smtp.yourmailserver.net' :maxlength="200" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-field :label="$t('settings.smtp.port')" label-position="on-border"
|
||||
:message="$t('settings.smtp.portHelp')">
|
||||
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
|
||||
:message="$t('settings.mailserver.portHelp')">
|
||||
<b-numberinput v-model="item.port" name="port" type="is-light"
|
||||
controls-position="compact"
|
||||
placeholder="25" min="1" max="65535" />
|
||||
|
@ -320,7 +320,7 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column is-2">
|
||||
<b-field :label="$t('settings.smtp.authProtocol')"
|
||||
<b-field :label="$t('settings.mailserver.authProtocol')"
|
||||
label-position="on-border">
|
||||
<b-select v-model="item.auth_protocol" name="auth_protocol">
|
||||
<option value="none">none</option>
|
||||
|
@ -332,19 +332,19 @@
|
|||
</div>
|
||||
<div class="column">
|
||||
<b-field grouped>
|
||||
<b-field :label="$t('settings.smtp.username')"
|
||||
<b-field :label="$t('settings.mailserver.username')"
|
||||
label-position="on-border" expanded>
|
||||
<b-input v-model="item.username"
|
||||
:disabled="item.auth_protocol === 'none'"
|
||||
name="username" placeholder="mysmtp" :maxlength="200" />
|
||||
</b-field>
|
||||
<b-field :label="$t('settings.smtp.password')"
|
||||
<b-field :label="$t('settings.mailserver.password')"
|
||||
label-position="on-border" expanded
|
||||
:message="$t('settings.smtp.passwordHelp')">
|
||||
:message="$t('settings.mailserver.passwordHelp')">
|
||||
<b-input v-model="item.password"
|
||||
:disabled="item.auth_protocol === 'none'"
|
||||
name="password" type="password"
|
||||
:placeholder="$t('settings.smtp.passwordHelp')"
|
||||
:placeholder="$t('settings.mailserver.passwordHelp')"
|
||||
:maxlength="200" />
|
||||
</b-field>
|
||||
</b-field>
|
||||
|
@ -362,12 +362,12 @@
|
|||
</div>
|
||||
<div class="column">
|
||||
<b-field grouped>
|
||||
<b-field :label="$t('settings.smtp.tls')" expanded
|
||||
:message="$t('settings.smtp.tlsHelp')">
|
||||
<b-field :label="$t('settings.mailserver.tls')" expanded
|
||||
:message="$t('settings.mailserver.tlsHelp')">
|
||||
<b-switch v-model="item.tls_enabled" name="item.tls_enabled" />
|
||||
</b-field>
|
||||
<b-field :label="$t('settings.smtp.skipTLS')" expanded
|
||||
:message="$t('settings.smtp.skipTLSHelp')">
|
||||
<b-field :label="$t('settings.mailserver.skipTLS')" expanded
|
||||
:message="$t('settings.mailserver.skipTLSHelp')">
|
||||
<b-switch v-model="item.tls_skip_verify"
|
||||
:disabled="!item.tls_enabled" name="item.tls_skip_verify" />
|
||||
</b-field>
|
||||
|
@ -378,8 +378,9 @@
|
|||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.smtp.maxConns')" label-position="on-border"
|
||||
:message="$t('settings.smtp.maxConnsHelp')">
|
||||
<b-field :label="$t('settings.mailserver.maxConns')"
|
||||
label-position="on-border"
|
||||
:message="$t('settings.mailserver.maxConnsHelp')">
|
||||
<b-numberinput v-model="item.max_conns" name="max_conns" type="is-light"
|
||||
controls-position="compact"
|
||||
placeholder="25" min="1" max="65535" />
|
||||
|
@ -395,15 +396,17 @@
|
|||
</b-field>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.smtp.idleTimeout')" label-position="on-border"
|
||||
:message="$t('settings.smtp.idleTimeoutHelp')">
|
||||
<b-field :label="$t('settings.mailserver.idleTimeout')"
|
||||
label-position="on-border"
|
||||
:message="$t('settings.mailserver.idleTimeoutHelp')">
|
||||
<b-input v-model="item.idle_timeout" name="idle_timeout"
|
||||
placeholder="15s" :pattern="regDuration" :maxlength="10" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.smtp.waitTimeout')" label-position="on-border"
|
||||
:message="$t('settings.smtp.waitTimeoutHelp')">
|
||||
<b-field :label="$t('settings.mailserver.waitTimeout')"
|
||||
label-position="on-border"
|
||||
:message="$t('settings.mailserver.waitTimeoutHelp')">
|
||||
<b-input v-model="item.wait_timeout" name="wait_timeout"
|
||||
placeholder="5s" :pattern="regDuration" :maxlength="10" />
|
||||
</b-field>
|
||||
|
@ -433,6 +436,174 @@
|
|||
</b-button>
|
||||
</b-tab-item><!-- mail servers -->
|
||||
|
||||
<b-tab-item :label="$t('settings.bounces.name')">
|
||||
<div class="columns mb-6">
|
||||
<div class="column">
|
||||
<b-field :label="$t('settings.bounces.enable')">
|
||||
<b-switch v-model="form['bounce.enabled']" name="bounce.enabled" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column" :class="{'disabled': !form['bounce.enabled']}">
|
||||
<b-field :label="$t('settings.bounces.count')" label-position="on-border"
|
||||
:message="$t('settings.bounces.countHelp')">
|
||||
<b-numberinput v-model="form['bounce.count']"
|
||||
name="bounce.count" type="is-light"
|
||||
controls-position="compact" placeholder="3" min="1" max="1000" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column" :class="{'disabled': !form['bounce.enabled']}">
|
||||
<b-field :label="$t('settings.bounces.action')" label-position="on-border">
|
||||
<b-select name="bounce.action" v-model="form['bounce.action']">
|
||||
<option value="blocklist">{{ $t('settings.bounces.blocklist') }}</option>
|
||||
<option value="delete">{{ $t('settings.bounces.delete') }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div><!-- columns -->
|
||||
|
||||
<div class="mb-6">
|
||||
<b-field :label="$t('settings.bounces.enableWebhooks')">
|
||||
<b-switch v-model="form['bounce.webhooks_enabled']"
|
||||
:disabled="!form['bounce.enabled']"
|
||||
name="webhooks_enabled" :native-value="true"
|
||||
data-cy="btn-enable-bounce-webhook" />
|
||||
<p class="has-text-grey">
|
||||
<a href="" target="_blank">{{ $t('globals.buttons.learnMore') }} →</a>
|
||||
</p>
|
||||
</b-field>
|
||||
<div class="box" v-if="form['bounce.webhooks_enabled']">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<b-field :label="$t('settings.bounces.enableSES')">
|
||||
<b-switch v-model="form['bounce.ses_enabled']"
|
||||
name="ses_enabled" :native-value="true" data-cy="btn-enable-bounce-ses" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.bounces.enableSendgrid')">
|
||||
<b-switch v-model="form['bounce.sendgrid_enabled']"
|
||||
name="sendgrid_enabled" :native-value="true"
|
||||
data-cy="btn-enable-bounce-sendgrid" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-field :label="$t('settings.bounces.sendgridKey')"
|
||||
:message="$t('globals.messages.passwordChange')">
|
||||
<b-input v-model="form['bounce.sendgrid_key']" type="password"
|
||||
:disabled="!form['bounce.sendgrid_enabled']"
|
||||
name="sendgrid_enabled" :native-value="true"
|
||||
data-cy="btn-enable-bounce-sendgrid" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bounce mailbox -->
|
||||
<b-field :label="$t('settings.bounces.enableMailbox')">
|
||||
<b-switch v-if="form['bounce.mailboxes']"
|
||||
v-model="form['bounce.mailboxes'][0].enabled"
|
||||
:disabled="!form['bounce.enabled']"
|
||||
name="enabled" :native-value="true" data-cy="btn-enable-bounce-mailbox" />
|
||||
</b-field>
|
||||
|
||||
<template v-if="form['bounce.enabled'] && form['bounce.mailboxes'][0].enabled">
|
||||
<div class="block box" v-for="(item, n) in form['bounce.mailboxes']" :key="n">
|
||||
<div class="columns">
|
||||
<div class="column" :class="{'disabled': !item.enabled}">
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.bounces.type')" label-position="on-border">
|
||||
<b-select v-model="item.type" name="type">
|
||||
<option value="pop">POP</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('settings.mailserver.host')" label-position="on-border"
|
||||
:message="$t('settings.mailserver.hostHelp')">
|
||||
<b-input v-model="item.host" name="host"
|
||||
placeholder='bounce.yourmailserver.net' :maxlength="200" />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.mailserver.port')" label-position="on-border"
|
||||
:message="$t('settings.mailserver.portHelp')">
|
||||
<b-numberinput v-model="item.port" name="port" type="is-light"
|
||||
controls-position="compact"
|
||||
placeholder="25" min="1" max="65535" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div><!-- host -->
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('settings.mailserver.authProtocol')"
|
||||
label-position="on-border">
|
||||
<b-select v-model="item.auth_protocol" name="auth_protocol">
|
||||
<option value="none">none</option>
|
||||
<option v-if="item.type === 'pop'" value="userpass">userpass</option>
|
||||
<template v-else>
|
||||
<option value="cram">cram</option>
|
||||
<option value="plain">plain</option>
|
||||
<option value="login">login</option>
|
||||
</template>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-field grouped>
|
||||
<b-field :label="$t('settings.mailserver.username')"
|
||||
label-position="on-border" expanded>
|
||||
<b-input v-model="item.username"
|
||||
:disabled="item.auth_protocol === 'none'"
|
||||
name="username" placeholder="mysmtp" :maxlength="200" />
|
||||
</b-field>
|
||||
<b-field :label="$t('settings.mailserver.password')"
|
||||
label-position="on-border" expanded
|
||||
:message="$t('settings.mailserver.passwordHelp')">
|
||||
<b-input v-model="item.password"
|
||||
:disabled="item.auth_protocol === 'none'"
|
||||
name="password" type="password"
|
||||
:placeholder="$t('settings.mailserver.passwordHelp')"
|
||||
:maxlength="200" />
|
||||
</b-field>
|
||||
</b-field>
|
||||
</div>
|
||||
</div><!-- auth -->
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field grouped>
|
||||
<b-field :label="$t('settings.mailserver.tls')" expanded
|
||||
:message="$t('settings.mailserver.tlsHelp')">
|
||||
<b-switch v-model="item.tls_enabled" name="item.tls_enabled" />
|
||||
</b-field>
|
||||
<b-field :label="$t('settings.mailserver.skipTLS')" expanded
|
||||
:message="$t('settings.mailserver.skipTLSHelp')">
|
||||
<b-switch v-model="item.tls_skip_verify"
|
||||
:disabled="!item.tls_enabled" name="item.tls_skip_verify" />
|
||||
</b-field>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column"></div>
|
||||
<div class="column is-4">
|
||||
<b-field :label="$t('settings.bounces.scanInterval')" expanded
|
||||
label-position="on-border"
|
||||
:message="$t('settings.bounces.scanIntervalHelp')">
|
||||
<b-input v-model="item.scan_interval" name="scan_interval"
|
||||
placeholder="15m" :pattern="regDuration" :maxlength="10" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div><!-- TLS -->
|
||||
</div>
|
||||
</div><!-- second container column -->
|
||||
</div><!-- block -->
|
||||
</template>
|
||||
</b-tab-item><!-- bounces -->
|
||||
|
||||
<b-tab-item :label="$t('settings.messengers.name')">
|
||||
<div class="items messengers">
|
||||
<div class="block box" v-for="(item, n) in form.messengers" :key="n">
|
||||
|
@ -583,6 +754,10 @@ export default Vue.extend({
|
|||
this.form.smtp.splice(i, 1);
|
||||
},
|
||||
|
||||
removeBounceBox(i) {
|
||||
this.form['bounce.mailboxes'].splice(i, 1);
|
||||
},
|
||||
|
||||
showSMTPHeaders(i) {
|
||||
const s = this.form.smtp[i];
|
||||
s.showHeaders = true;
|
||||
|
@ -615,7 +790,7 @@ export default Vue.extend({
|
|||
onSubmit() {
|
||||
const form = JSON.parse(JSON.stringify(this.form));
|
||||
|
||||
// De-serialize custom e-mail headers.
|
||||
// SMTP boxes.
|
||||
for (let i = 0; i < form.smtp.length; i += 1) {
|
||||
// If it's the dummy UI password placeholder, ignore it.
|
||||
if (form.smtp[i].password === dummyPassword) {
|
||||
|
@ -629,10 +804,22 @@ export default Vue.extend({
|
|||
}
|
||||
}
|
||||
|
||||
// Bounces boxes.
|
||||
for (let i = 0; i < form['bounce.mailboxes'].length; i += 1) {
|
||||
// If it's the dummy UI password placeholder, ignore it.
|
||||
if (form['bounce.mailboxes'][i].password === dummyPassword) {
|
||||
form['bounce.mailboxes'][i].password = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (form['upload.s3.aws_secret_access_key'] === dummyPassword) {
|
||||
form['upload.s3.aws_secret_access_key'] = '';
|
||||
}
|
||||
|
||||
if (form['bounce.sendgrid_key'] === dummyPassword) {
|
||||
form['bounce.sendgrid_key'] = '';
|
||||
}
|
||||
|
||||
for (let i = 0; i < form.messengers.length; i += 1) {
|
||||
// If it's the dummy UI password placeholder, ignore it.
|
||||
if (form.messengers[i].password === dummyPassword) {
|
||||
|
@ -680,6 +867,12 @@ export default Vue.extend({
|
|||
d.smtp[i].password = dummyPassword;
|
||||
}
|
||||
|
||||
for (let i = 0; i < d['bounce.mailboxes'].length; i += 1) {
|
||||
// The backend doesn't send passwords, so add a dummy so that
|
||||
// the password looks filled on the UI.
|
||||
d['bounce.mailboxes'][i].password = dummyPassword;
|
||||
}
|
||||
|
||||
for (let i = 0; i < d.messengers.length; i += 1) {
|
||||
// The backend doesn't send passwords, so add a dummy so that it
|
||||
// the password looks filled on the UI.
|
||||
|
@ -689,10 +882,14 @@ export default Vue.extend({
|
|||
if (d['upload.provider'] === 's3') {
|
||||
d['upload.s3.aws_secret_access_key'] = dummyPassword;
|
||||
}
|
||||
d['bounce.sendgrid_key'] = dummyPassword;
|
||||
|
||||
this.form = d;
|
||||
this.formCopy = JSON.stringify(d);
|
||||
this.isLoading = false;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<form @submit.prevent="onSubmit">
|
||||
<div class="modal-card content" style="width: auto">
|
||||
<header class="modal-card-head">
|
||||
|
||||
<b-tag v-if="isEditing" :class="[data.status, 'is-pulled-right']">{{ data.status }}</b-tag>
|
||||
<h4 v-if="isEditing">{{ data.name }}</h4>
|
||||
<h4 v-else>{{ $t('subscribers.newSubscriber') }}</h4>
|
||||
|
@ -12,25 +11,31 @@
|
|||
{{ $t('globals.fields.uuid') }}: {{ data.uuid }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('subscribers.email')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.email" name="email" :ref="'focus'"
|
||||
:placeholder="$t('subscribers.email')" required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name"
|
||||
:placeholder="$t('globals.fields.name')"></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border"
|
||||
:message="$t('subscribers.blocklistedHelp')">
|
||||
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')"
|
||||
required>
|
||||
<option value="enabled">{{ $t('subscribers.status.enabled') }}</option>
|
||||
<option value="blocklisted">{{ $t('subscribers.status.blocklisted') }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<div class="columns">
|
||||
<div class="column is-8">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.name" name="name"
|
||||
:placeholder="$t('globals.fields.name')"></b-input>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border"
|
||||
:message="$t('subscribers.blocklistedHelp')">
|
||||
<b-select v-model="form.status" name="status"
|
||||
:placeholder="$t('globals.fields.status')" required expanded>
|
||||
<option value="enabled">{{ $t('subscribers.status.enabled') }}</option>
|
||||
<option value="blocklisted">{{ $t('subscribers.status.blocklisted') }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<list-selector
|
||||
:label="$t('subscribers.lists')"
|
||||
|
@ -43,12 +48,48 @@
|
|||
|
||||
<b-field :label="$t('subscribers.attribs')" label-position="on-border"
|
||||
:message="$t('subscribers.attribsHelp') + ' ' + egAttribs">
|
||||
<b-input v-model="form.strAttribs" name="attribs" type="textarea" />
|
||||
<div>
|
||||
<b-input v-model="form.strAttribs" name="attribs" type="textarea" />
|
||||
<a href="https://listmonk.app/docs/concepts"
|
||||
target="_blank" rel="noopener noreferrer" class="is-size-7">
|
||||
{{ $t('globals.buttons.learnMore') }} <b-icon icon="link-variant" size="is-small" />
|
||||
</a>
|
||||
</div>
|
||||
</b-field>
|
||||
<a href="https://listmonk.app/docs/concepts"
|
||||
target="_blank" rel="noopener noreferrer" class="is-size-7">
|
||||
{{ $t('globals.buttons.learnMore') }} <b-icon icon="link" size="is-small" />.
|
||||
</a>
|
||||
|
||||
<div class="bounces" v-show="bounces.length > 0">
|
||||
<a href="#" class="is-size-6" disabed="true"
|
||||
@click.prevent="toggleBounces">
|
||||
<b-icon icon="email-bounce"></b-icon>
|
||||
{{ $t('bounces.view') }} ({{ bounces.length }})
|
||||
</a>
|
||||
<a href="#" class="is-size-6 is-pulled-right" disabed="true"
|
||||
@click.prevent="deleteBounces" v-if="isBounceVisible">
|
||||
<b-icon icon="trash-can-outline"></b-icon>
|
||||
{{ $t('globals.buttons.delete') }}
|
||||
</a>
|
||||
|
||||
<div v-if="isBounceVisible" class="mt-4">
|
||||
<ol class="is-size-7">
|
||||
<li v-for="b in bounces" :key="b.id" class="mb-2">
|
||||
<div v-if="b.campaign">
|
||||
<router-link :to="{ name: 'bounces', query: { campaign_id: b.campaign.id } }">
|
||||
{{ b.campaign.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
{{ $utils.niceDate(b.createdAt, true) }}
|
||||
<span class="is-pulled-right">
|
||||
<a href="#" @click.prevent="toggleMeta(b.id)">
|
||||
{{ b.source }}
|
||||
<b-icon :icon="visibleMeta[b.id] ? 'arrow-up' : 'arrow-down'" />
|
||||
</a>
|
||||
</span>
|
||||
<span class="is-clearfix"></span>
|
||||
<pre v-if="visibleMeta[b.id]">{{ b.meta }}</pre>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
|
||||
|
@ -82,12 +123,45 @@ export default Vue.extend({
|
|||
// Binds form input values. This is populated by subscriber props passed
|
||||
// from the parent component in mounted().
|
||||
form: { lists: [], strAttribs: '{}' },
|
||||
isBounceVisible: false,
|
||||
bounces: [],
|
||||
visibleMeta: {},
|
||||
|
||||
egAttribs: '{"job": "developer", "location": "Mars", "has_rocket": true}',
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleBounces() {
|
||||
this.isBounceVisible = !this.isBounceVisible;
|
||||
},
|
||||
|
||||
toggleMeta(id) {
|
||||
let v = false;
|
||||
if (!this.visibleMeta[id]) {
|
||||
v = true;
|
||||
}
|
||||
Vue.set(this.visibleMeta, id, v);
|
||||
},
|
||||
|
||||
deleteBounces(sub) {
|
||||
this.$utils.confirm(
|
||||
null,
|
||||
() => {
|
||||
this.$api.deleteSubscriberBounces(this.form.id).then(() => {
|
||||
this.getBounces();
|
||||
this.$utils.toast(this.$t('globals.messages.deleted', { name: sub.name }));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
getBounces() {
|
||||
this.$api.getSubscriberBounces(this.form.id).then((data) => {
|
||||
this.bounces = data;
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
if (this.isEditing) {
|
||||
this.updateSubscriber();
|
||||
|
@ -183,6 +257,11 @@ export default Vue.extend({
|
|||
};
|
||||
}
|
||||
|
||||
if (this.form.id) {
|
||||
this.getBounces();
|
||||
}
|
||||
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
|
|
|
@ -198,7 +198,8 @@
|
|||
</b-modal>
|
||||
|
||||
<!-- Add / edit form modal -->
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600">
|
||||
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600"
|
||||
@close="onFormClose">
|
||||
<subscriber-form :data="curItem" :isEditing="isEditing"
|
||||
@finished="querySubscribers"></subscriber-form>
|
||||
</b-modal>
|
||||
|
@ -309,6 +310,12 @@ export default Vue.extend({
|
|||
this.isBulkListFormVisible = true;
|
||||
},
|
||||
|
||||
onFormClose() {
|
||||
if (this.$route.params.id) {
|
||||
this.$router.push({ name: 'subscribers' });
|
||||
}
|
||||
},
|
||||
|
||||
onPageChange(p) {
|
||||
this.queryParams.page = p;
|
||||
this.querySubscribers();
|
||||
|
@ -472,8 +479,14 @@ export default Vue.extend({
|
|||
this.queryParams.listID = parseInt(this.$route.params.listID, 10);
|
||||
}
|
||||
|
||||
// Get subscribers on load.
|
||||
this.querySubscribers();
|
||||
if (this.$route.params.id) {
|
||||
this.$api.getSubscriber(parseInt(this.$route.params.id, 10)).then((data) => {
|
||||
this.showEditForm(data);
|
||||
});
|
||||
} else {
|
||||
// Get subscribers on load.
|
||||
this.querySubscribers();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
1
go.mod
1
go.mod
|
@ -8,6 +8,7 @@ require (
|
|||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gofrs/uuid v3.2.0+incompatible
|
||||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/knadh/go-pop3 v0.1.0
|
||||
github.com/knadh/goyesql/v2 v2.1.1
|
||||
github.com/knadh/koanf v0.12.0
|
||||
github.com/knadh/smtppool v0.2.1
|
||||
|
|
9
go.sum
9
go.sum
|
@ -13,6 +13,10 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
|
|||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
|
@ -33,6 +37,8 @@ github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
|
|||
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/knadh/go-pop3 v0.1.0 h1:MECWomq2uEGeuR7O2TjfzD63H47UFLKOqH1bSH7yhRU=
|
||||
github.com/knadh/go-pop3 v0.1.0/go.mod h1:a5kUJzrBB6kec+tNJl+3Z64ROgByKBdcyub+mhZMAfI=
|
||||
github.com/knadh/goyesql v2.0.0+incompatible h1:hJFJrU8kaiLmvYt9I/1k1AB7q+qRhHs/afzTfQ3eGqk=
|
||||
github.com/knadh/goyesql v2.0.0+incompatible/go.mod h1:W0tSzU8l7lYH1Fihj+bdQzkzOwvirrsMNHwkuY22qoY=
|
||||
github.com/knadh/goyesql/v2 v2.1.1 h1:Orp5ldaxPM4ozKHfu1m7p6iolJFXDGOpF3/jyOgO6ls=
|
||||
|
@ -117,8 +123,9 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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=
|
||||
|
|
65
i18n/de.json
65
i18n/de.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "de",
|
||||
"_.name": "Deutsch (de)",
|
||||
"admin.errorMarshallingConfig": "Fehler beim einlesen der Konfiguration: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Füge eine alternative Plain-Text Nachricht hinzu (falls HTML nicht angezeigt werden kann).",
|
||||
"campaigns.cantUpdate": "Eine laufende oder abgeschlossene Kampagne kann nicht geändert werden.",
|
||||
"campaigns.clicks": "Klicks",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Schließen",
|
||||
"globals.buttons.continue": "Fortfahren",
|
||||
"globals.buttons.delete": "Löschen",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Bearbeiten",
|
||||
"globals.buttons.enabled": "Aktiviert",
|
||||
"globals.buttons.learnMore": "Erfahre mehr",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Bist du sicher?",
|
||||
"globals.messages.created": "\"{name}\" erstellt",
|
||||
"globals.messages.deleted": "\"{name}\" gelöscht",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Hier ist nichts",
|
||||
"globals.messages.errorCreating": "Fehler beim Erstellen von {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Fehler beim Löschen von {name}: {error}",
|
||||
"globals.messages.errorFetching": "Fehler beim Abrufen von {name}: {error}",
|
||||
"globals.messages.errorUUID": "Fehler beim Erzeugen einer UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Fehler beim Aktualisieren von {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "Ungültige ID",
|
||||
"globals.messages.invalidUUID": "Ungültige UUID",
|
||||
"globals.messages.notFound": "{name} nicht gefunden",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Jul",
|
||||
"globals.months.8": "Aug",
|
||||
"globals.months.9": "Sep",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Kampagne | Kampagnen",
|
||||
"globals.terms.campaigns": "Kampagnen",
|
||||
"globals.terms.dashboard": "Überblick",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Du wurdest erfolgreich abgemeldet",
|
||||
"public.unsubbedTitle": "Abgemeldet",
|
||||
"public.unsubscribeTitle": "Von E-Mail Liste abmelden.",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Stelle sicher, dass laufende Kampagnen pausiert sind. Neustarten?",
|
||||
"settings.duplicateMessengerName": "Doppelter Nachrichtendienstname: {name}",
|
||||
"settings.errorEncoding": "Fehler bei der Kodierung der Einstellungen: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "Root URL",
|
||||
"settings.general.rootURLHelp": "Öffentliche URL der Installation (ohne Slash am Ende).",
|
||||
"settings.invalidMessengerName": "Der Name des Nachrichtendienst ist ungültig",
|
||||
"settings.mailserver.authProtocol": "Autentifizierungsprotokoll",
|
||||
"settings.mailserver.host": "Server",
|
||||
"settings.mailserver.hostHelp": "SMTP Server Adresse.",
|
||||
"settings.mailserver.idleTimeout": "Maximale Wartezeit",
|
||||
"settings.mailserver.idleTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
|
||||
"settings.mailserver.maxConns": "Max. Verbindungen",
|
||||
"settings.mailserver.maxConnsHelp": "Maximale gleichzeitige Verbindungen zum SMTP Server",
|
||||
"settings.mailserver.password": "Passwort",
|
||||
"settings.mailserver.passwordHelp": "Gib dein Passwort ein, um es zu ändern",
|
||||
"settings.mailserver.port": "Port",
|
||||
"settings.mailserver.portHelp": "SMTP Server Port.",
|
||||
"settings.mailserver.skipTLS": "TLS Verifikation überspringen",
|
||||
"settings.mailserver.skipTLSHelp": "Überspringe die Hostname Prüfung im TLS Zertifikat.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Verwende STARTTLS.",
|
||||
"settings.mailserver.username": "Benutzername",
|
||||
"settings.mailserver.waitTimeout": "Maximale Wartezeit",
|
||||
"settings.mailserver.waitTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
|
||||
"settings.media.provider": "Anbieter",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Bucket Pfad",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Inkludiere Header zum einfachen Abmelden in den E-Mails. Erlaubt es, den E-Mail Clients der Nutzer eine \",Ein Klick\"-Abmeldung anzubieten.",
|
||||
"settings.privacy.name": "Privatsphäre",
|
||||
"settings.restart": "Neustarten",
|
||||
"settings.smtp.authProtocol": "Autentifizierungsprotokoll",
|
||||
"settings.smtp.customHeaders": "Benutzerdefinierte Header",
|
||||
"settings.smtp.customHeadersHelp": "(Optional) Array von benutzerdefinierten E-Mail Headern, welche in die Nachricht eingefügt werden sollen. Z.B.: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Aktiviert",
|
||||
"settings.smtp.heloHost": "HELO Hostname",
|
||||
"settings.smtp.heloHostHelp": "(Optional) Manche SMTP Server benötigen ein FQDN Hostname im HELO. Standard ist dieser `localhost`. Wenn du eienen anderen brauchst, kannst du ihn hier ändern.",
|
||||
"settings.smtp.host": "Server",
|
||||
"settings.smtp.hostHelp": "SMTP Server Adresse.",
|
||||
"settings.smtp.idleTimeout": "Maximale Wartezeit",
|
||||
"settings.smtp.idleTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
|
||||
"settings.smtp.maxConns": "Max. Verbindungen",
|
||||
"settings.smtp.maxConnsHelp": "Maximale gleichzeitige Verbindungen zum SMTP Server",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Passwort",
|
||||
"settings.smtp.passwordHelp": "Gib dein Passwort ein, um es zu ändern",
|
||||
"settings.smtp.port": "Port",
|
||||
"settings.smtp.portHelp": "SMTP Server Port.",
|
||||
"settings.smtp.retries": "Wiederholungen",
|
||||
"settings.smtp.retriesHelp": "Maximale Anzahl an Wiederholungen, wenn eine Machricht fehlschlägt.",
|
||||
"settings.smtp.setCustomHeaders": "Benutzerdefinierten Header verwenden",
|
||||
"settings.smtp.skipTLS": "TLS Verifikation überspringen",
|
||||
"settings.smtp.skipTLSHelp": "Überspringe die Hostname Prüfung im TLS Zertifikat.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Verwende STARTTLS.",
|
||||
"settings.smtp.username": "Benutzername",
|
||||
"settings.smtp.waitTimeout": "Maximale Wartezeit",
|
||||
"settings.smtp.waitTimeoutHelp": "Wartezeit auf neue Aktivität bevor eine Verbindung geschlossen wird. (s für Sekunden, m für Minuten).",
|
||||
"settings.title": "Einstellungen",
|
||||
"settings.updateAvailable": "Ein neues Update auf {version} ist verfügbar.",
|
||||
"subscribers.advancedQuery": "Erweitert",
|
||||
|
|
69
i18n/en.json
69
i18n/en.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "en",
|
||||
"_.name": "English (en)",
|
||||
"admin.errorMarshallingConfig": "Error marshalling config: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Add alternate plain text message",
|
||||
"campaigns.cantUpdate": "Cannot update a running or a finished campaign.",
|
||||
"campaigns.clicks": "Clicks",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Close",
|
||||
"globals.buttons.continue": "Continue",
|
||||
"globals.buttons.delete": "Delete",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Edit",
|
||||
"globals.buttons.enabled": "Enabled",
|
||||
"globals.buttons.learnMore": "Learn more",
|
||||
|
@ -130,14 +134,17 @@
|
|||
"globals.messages.confirm": "Are you sure?",
|
||||
"globals.messages.created": "\"{name}\" created",
|
||||
"globals.messages.deleted": "\"{name}\" deleted",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Nothing here",
|
||||
"globals.messages.errorCreating": "Error creating {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Error deleting {name}: {error}",
|
||||
"globals.messages.errorFetching": "Error fetching {name}: {error}",
|
||||
"globals.messages.errorUUID": "Error generating UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Error updating {name}: {error}",
|
||||
"globals.messages.invalidID": "Invalid ID",
|
||||
"globals.messages.invalidUUID": "Invalid UUID",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "Invalid ID(s)",
|
||||
"globals.messages.invalidUUID": "Invalid UUID(s)",
|
||||
"globals.messages.notFound": "{name} not found",
|
||||
"globals.messages.passwordChange": "Enter a value to change",
|
||||
"globals.messages.updated": "\"{name}\" updated",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Jul",
|
||||
"globals.months.8": "Aug",
|
||||
"globals.months.9": "Sep",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campaign | Campaigns",
|
||||
"globals.terms.campaigns": "Campaigns",
|
||||
"globals.terms.dashboard": "Dashboard",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "You have unsubscribed successfully.",
|
||||
"public.unsubbedTitle": "Unsubscribed",
|
||||
"public.unsubscribeTitle": "Unsubscribe from mailing list",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
|
||||
"settings.duplicateMessengerName": "Duplicate messenger name: {name}",
|
||||
"settings.errorEncoding": "Error encoding settings: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "Root URL",
|
||||
"settings.general.rootURLHelp": "Public URL of the installation (no trailing slash).",
|
||||
"settings.invalidMessengerName": "Invalid messenger name.",
|
||||
"settings.mailserver.authProtocol": "Auth protocol",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "SMTP server's host address.",
|
||||
"settings.mailserver.idleTimeout": "Idle timeout",
|
||||
"settings.mailserver.idleTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
|
||||
"settings.mailserver.maxConns": "Max. connections",
|
||||
"settings.mailserver.maxConnsHelp": "Maximum concurrent connections to the server.",
|
||||
"settings.mailserver.password": "Password",
|
||||
"settings.mailserver.passwordHelp": "Enter to change",
|
||||
"settings.mailserver.port": "Port",
|
||||
"settings.mailserver.portHelp": "SMTP server's port.",
|
||||
"settings.mailserver.skipTLS": "Skip TLS verification",
|
||||
"settings.mailserver.skipTLSHelp": "Skip hostname check on the TLS certificate.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Enable STARTTLS.",
|
||||
"settings.mailserver.username": "Username",
|
||||
"settings.mailserver.waitTimeout": "Wait timeout",
|
||||
"settings.mailserver.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.media.provider": "Provider",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Bucket path",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
|
||||
"settings.privacy.name": "Privacy",
|
||||
"settings.restart": "Restart",
|
||||
"settings.smtp.authProtocol": "Auth protocol",
|
||||
"settings.smtp.customHeaders": "Custom headers",
|
||||
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Enabled",
|
||||
"settings.smtp.heloHost": "HELO hostname",
|
||||
"settings.smtp.heloHostHelp": "Optional. Some SMTP servers require a FQDN in the hostname. By default, HELLOs go with `localhost`. Set this if a custom hostname should be used.",
|
||||
"settings.smtp.host": "Host",
|
||||
"settings.smtp.hostHelp": "SMTP server's host address.",
|
||||
"settings.smtp.idleTimeout": "Idle timeout",
|
||||
"settings.smtp.idleTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
|
||||
"settings.smtp.maxConns": "Max. connections",
|
||||
"settings.smtp.maxConnsHelp": "Maximum concurrent connections to the SMTP server.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Password",
|
||||
"settings.smtp.passwordHelp": "Enter to change",
|
||||
"settings.smtp.port": "Port",
|
||||
"settings.smtp.portHelp": "SMTP server's port.",
|
||||
"settings.smtp.retries": "Retries",
|
||||
"settings.smtp.retriesHelp": "Number of times to retry when a message fails.",
|
||||
"settings.smtp.setCustomHeaders": "Set custom headers",
|
||||
"settings.smtp.skipTLS": "Skip TLS verification",
|
||||
"settings.smtp.skipTLSHelp": "Skip hostname check on the TLS certificate.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Enable STARTTLS.",
|
||||
"settings.smtp.username": "Username",
|
||||
"settings.smtp.waitTimeout": "Wait timeout",
|
||||
"settings.smtp.waitTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
|
||||
"settings.title": "Settings",
|
||||
"settings.updateAvailable": "A new update {version} is available.",
|
||||
"subscribers.advancedQuery": "Advanced",
|
||||
|
|
65
i18n/es.json
65
i18n/es.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "es",
|
||||
"_.name": "Español (es)",
|
||||
"admin.errorMarshallingConfig": "Error al ordenar la configuración: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Agregar mensaje en texto plano alternativo",
|
||||
"campaigns.cantUpdate": "No es posible actualizar una campaña iniciada o finalizada.",
|
||||
"campaigns.clicks": "Clics",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Cerrar",
|
||||
"globals.buttons.continue": "Continuar",
|
||||
"globals.buttons.delete": "Borrar",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Editar",
|
||||
"globals.buttons.enabled": "Habilitar",
|
||||
"globals.buttons.learnMore": "Conocer más",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "¿Está seguro?",
|
||||
"globals.messages.created": "\"{name}\" creado",
|
||||
"globals.messages.deleted": "\"{name}\" borrado",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Vacío",
|
||||
"globals.messages.errorCreating": "Error creando {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Error borrando {name}: {error}",
|
||||
"globals.messages.errorFetching": "Error buscando {name}: {error}",
|
||||
"globals.messages.errorUUID": "Error generando UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Error actualizando {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ID inválido",
|
||||
"globals.messages.invalidUUID": "UUID inválido",
|
||||
"globals.messages.notFound": "{name} no encontrado",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Julio",
|
||||
"globals.months.8": "Agosto",
|
||||
"globals.months.9": "Setiembre",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campaña | Campañas",
|
||||
"globals.terms.campaigns": "Campañas",
|
||||
"globals.terms.dashboard": "Panel",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Ud. se ha des-subscrito de forma satisfactoria",
|
||||
"public.unsubbedTitle": "Des-subscrito.",
|
||||
"public.unsubscribeTitle": "Des-subscribirse de una lista de correo",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Asegúrese de que las campañas ejecutándose están en pause. ¿Reiniciar?",
|
||||
"settings.duplicateMessengerName": "Nombre de mensajero duplicado: {name}",
|
||||
"settings.errorEncoding": "Error codificando configuración: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "URL raíz",
|
||||
"settings.general.rootURLHelp": "URL pública de la instalación (sin la barra final)",
|
||||
"settings.invalidMessengerName": "Nombre de mensajero inválido.",
|
||||
"settings.mailserver.authProtocol": "Protocolo de autenticación",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "Dirección del servidor SMTP",
|
||||
"settings.mailserver.idleTimeout": "Timeout por inactividad",
|
||||
"settings.mailserver.idleTimeoutHelp": "Tiempo de espara para nueva actividad en una conexión antes de cerrarla y elminarla del pool (s para segundos, m para minutos).",
|
||||
"settings.mailserver.maxConns": "Conexiones máximas",
|
||||
"settings.mailserver.maxConnsHelp": "Número máximo de conexiones concurrentes hacia el servidor SMTP.",
|
||||
"settings.mailserver.password": "Contraseña",
|
||||
"settings.mailserver.passwordHelp": "Ingresar contraseña para cambiar",
|
||||
"settings.mailserver.port": "Puerto",
|
||||
"settings.mailserver.portHelp": "Puerto del servidor SMTP",
|
||||
"settings.mailserver.skipTLS": "Omitir verificación de TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Omitir la verificación del nombre de servidor en un certificado TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Habilitar STARTTLS",
|
||||
"settings.mailserver.username": "Nombre de usuario",
|
||||
"settings.mailserver.waitTimeout": "Timeout de espera",
|
||||
"settings.mailserver.waitTimeoutHelp": "Tiempo de espera para nueva actividad en una conexión antes de cerrarla y eliminarla del pool (s para segundos, m para minutos).",
|
||||
"settings.media.provider": "Proveedor",
|
||||
"settings.media.s3.bucket": "Contenedor",
|
||||
"settings.media.s3.bucketPath": "Ruta del contenedor",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Incluye los encabezados de des-subscripción para habilitar a los clientes de correo para permitir a los usuarios des-subscribirse con un solo clic.",
|
||||
"settings.privacy.name": "Privacidad",
|
||||
"settings.restart": "Reiniciar",
|
||||
"settings.smtp.authProtocol": "Protocolo de autenticación",
|
||||
"settings.smtp.customHeaders": "Encabezados personalizados",
|
||||
"settings.smtp.customHeadersHelp": "Lista de encabezados opcionales a incluir en todos los mensajes enviados desde este servidor. Por ejemplo {{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Habilitado",
|
||||
"settings.smtp.heloHost": "HELO hostname",
|
||||
"settings.smtp.heloHostHelp": "Opcional. Algunos servidores SMTP requieren un FQDN en el nombre de host. Por defecto se usa 'localhost' cmo dato HELLO. Configurar aquí un nombre de host específico en caso se ser requerido.",
|
||||
"settings.smtp.host": "Host",
|
||||
"settings.smtp.hostHelp": "Dirección del servidor SMTP",
|
||||
"settings.smtp.idleTimeout": "Timeout por inactividad",
|
||||
"settings.smtp.idleTimeoutHelp": "Tiempo de espara para nueva actividad en una conexión antes de cerrarla y elminarla del pool (s para segundos, m para minutos).",
|
||||
"settings.smtp.maxConns": "Conexiones máximas",
|
||||
"settings.smtp.maxConnsHelp": "Número máximo de conexiones concurrentes hacia el servidor SMTP.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Contraseña",
|
||||
"settings.smtp.passwordHelp": "Ingresar contraseña para cambiar",
|
||||
"settings.smtp.port": "Puerto",
|
||||
"settings.smtp.portHelp": "Puerto del servidor SMTP",
|
||||
"settings.smtp.retries": "Reintentos",
|
||||
"settings.smtp.retriesHelp": "Número de reintentos cuando un mensaje falla.",
|
||||
"settings.smtp.setCustomHeaders": "Configurar encabezados personalizados.",
|
||||
"settings.smtp.skipTLS": "Omitir verificación de TLS",
|
||||
"settings.smtp.skipTLSHelp": "Omitir la verificación del nombre de servidor en un certificado TLS.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Habilitar STARTTLS",
|
||||
"settings.smtp.username": "Nombre de usuario",
|
||||
"settings.smtp.waitTimeout": "Timeout de espera",
|
||||
"settings.smtp.waitTimeoutHelp": "Tiempo de espera para nueva actividad en una conexión antes de cerrarla y eliminarla del pool (s para segundos, m para minutos).",
|
||||
"settings.title": "Configuraciones",
|
||||
"settings.updateAvailable": "Una actualización {version} está disponible.",
|
||||
"subscribers.advancedQuery": "Avanzado",
|
||||
|
|
65
i18n/fr.json
65
i18n/fr.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "fr",
|
||||
"_.name": "Français (fr)",
|
||||
"admin.errorMarshallingConfig": "Erreur lors de la lecture de la configuration : {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Ajouter un message alternatif en texte brut",
|
||||
"campaigns.cantUpdate": "Impossible de mettre à jour une campagne en cours ou terminée.",
|
||||
"campaigns.clicks": "clics",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Fermer",
|
||||
"globals.buttons.continue": "Continuer",
|
||||
"globals.buttons.delete": "Supprimer",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Éditer",
|
||||
"globals.buttons.enabled": "Activé·e",
|
||||
"globals.buttons.learnMore": "En savoir plus",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Confirmer ?",
|
||||
"globals.messages.created": "Création de \"{name}\"",
|
||||
"globals.messages.deleted": "Suppression de \"{name}\"",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Rien",
|
||||
"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.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.errorUpdating": "Erreur lors de la mise à jour de {name} : {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ID invalide",
|
||||
"globals.messages.invalidUUID": "UUID invalide",
|
||||
"globals.messages.notFound": "{name} introuvable",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "juil.",
|
||||
"globals.months.8": "août",
|
||||
"globals.months.9": "sept.",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campagne | Campagnes",
|
||||
"globals.terms.campaigns": "Campagnes",
|
||||
"globals.terms.dashboard": "Tableau de bord",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Vous vous êtes désabonné·e avec succès.",
|
||||
"public.unsubbedTitle": "Désabonné·e",
|
||||
"public.unsubscribeTitle": "Se désabonner de la liste de diffusion",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Assurez-vous que les campagnes actives soient en pause. Redémarrer ?",
|
||||
"settings.duplicateMessengerName": "Doublon du nom de messagerie : {name}",
|
||||
"settings.errorEncoding": "Erreur lors de l'encodage des paramètres : {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "URL racine",
|
||||
"settings.general.rootURLHelp": "URL publique de l'installation (sans slash final)",
|
||||
"settings.invalidMessengerName": "Nom de messagerie invalide",
|
||||
"settings.mailserver.authProtocol": "Protocole d'authentification",
|
||||
"settings.mailserver.host": "Hôte",
|
||||
"settings.mailserver.hostHelp": "Adresse hôte du serveur SMTP",
|
||||
"settings.mailserver.idleTimeout": "Délai d'inactivité",
|
||||
"settings.mailserver.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.mailserver.maxConns": "Nb. de connexions max.",
|
||||
"settings.mailserver.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur SMTP",
|
||||
"settings.mailserver.password": "Mot de passe",
|
||||
"settings.mailserver.passwordHelp": "Entrez un nouveau mot de passe si vous souhaitez le modifier",
|
||||
"settings.mailserver.port": "Port",
|
||||
"settings.mailserver.portHelp": "Port du serveur SMTP",
|
||||
"settings.mailserver.skipTLS": "Ignorer la vérification TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Activer STARTTLS",
|
||||
"settings.mailserver.username": "Nom d'utilisateur",
|
||||
"settings.mailserver.waitTimeout": "Délai d'attente",
|
||||
"settings.mailserver.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.media.provider": "Fournisseur",
|
||||
"settings.media.s3.bucket": "Compartiment",
|
||||
"settings.media.s3.bucketPath": "Chemin du compartiment",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"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.restart": "Redémarrer",
|
||||
"settings.smtp.authProtocol": "Protocole d'authentification",
|
||||
"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.enabled": "Activé",
|
||||
"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.host": "Hôte",
|
||||
"settings.smtp.hostHelp": "Adresse hôte du serveur SMTP",
|
||||
"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.maxConns": "Nb. de connexions max.",
|
||||
"settings.smtp.maxConnsHelp": "Nombre maximum de connexions simultanées au serveur SMTP",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Mot de passe",
|
||||
"settings.smtp.passwordHelp": "Entrez un nouveau mot de passe si vous souhaitez le modifier",
|
||||
"settings.smtp.port": "Port",
|
||||
"settings.smtp.portHelp": "Port du serveur SMTP",
|
||||
"settings.smtp.retries": "Tentatives de renvoi",
|
||||
"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.skipTLS": "Ignorer la vérification TLS",
|
||||
"settings.smtp.skipTLSHelp": "Ignorer la vérification du nom d'hôte sur le certificat TLS",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Activer STARTTLS",
|
||||
"settings.smtp.username": "Nom d'utilisateur",
|
||||
"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.title": "Paramètres",
|
||||
"settings.updateAvailable": "Une nouvelle version ({version}) est disponible.",
|
||||
"subscribers.advancedQuery": "Requête avancée",
|
||||
|
|
65
i18n/it.json
65
i18n/it.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "it",
|
||||
"_.name": "Italiano (it)",
|
||||
"admin.errorMarshallingConfig": "Errore durante la lettura della configurazione: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Aggiungere un messaggio sostitutivo in testo semplice",
|
||||
"campaigns.cantUpdate": "Impossibile aggiornare una campagna in corso o già effettuata.",
|
||||
"campaigns.clicks": "Clic",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Chiudi",
|
||||
"globals.buttons.continue": "Continuare",
|
||||
"globals.buttons.delete": "Cancellare",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Modifica",
|
||||
"globals.buttons.enabled": "Attivata",
|
||||
"globals.buttons.learnMore": "Per saperne di più",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Sei sicuro?",
|
||||
"globals.messages.created": "\"{name}\" creato",
|
||||
"globals.messages.deleted": "\"{name}\" cancellato",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"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.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ID non valido",
|
||||
"globals.messages.invalidUUID": "UUID non valido",
|
||||
"globals.messages.notFound": "{name} introvabile",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Lug",
|
||||
"globals.months.8": "Ago",
|
||||
"globals.months.9": "Set",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campagna | Campagne",
|
||||
"globals.terms.campaigns": "Campagne",
|
||||
"globals.terms.dashboard": "Tabella di marcia",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "La cancellazione è avvenuta con successo.",
|
||||
"public.unsubbedTitle": "Iscrizione annullata",
|
||||
"public.unsubscribeTitle": "Cancella l'iscrizione dalla lista di diffusione",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Asicurati che le campagne sono in pausa. Riavviare?",
|
||||
"settings.duplicateMessengerName": "Nome in messaggeria doppio: {name}",
|
||||
"settings.errorEncoding": "Errore durante la codifica dei parametri: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"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.mailserver.authProtocol": "Protocollo di autenticazione",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "Indirizzo host del server SMTP.",
|
||||
"settings.mailserver.idleTimeout": "Periodo di inattività",
|
||||
"settings.mailserver.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.mailserver.maxConns": "Nb. connessioni max.",
|
||||
"settings.mailserver.maxConnsHelp": "Numero massimo di connessioni simultanee al server SMTP.",
|
||||
"settings.mailserver.password": "Password",
|
||||
"settings.mailserver.passwordHelp": "Entra per modificare",
|
||||
"settings.mailserver.port": "Porto",
|
||||
"settings.mailserver.portHelp": "Porta del server SMTP.",
|
||||
"settings.mailserver.skipTLS": "Ignora controllo TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Ignora la verifica del nome dell'host sul certificato TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Attiva STARTTLS.",
|
||||
"settings.mailserver.username": "Nome utente",
|
||||
"settings.mailserver.waitTimeout": "Tempo d'attesa",
|
||||
"settings.mailserver.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.media.provider": "Fornitore",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Percorso del bucket",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"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": "Riavviare",
|
||||
"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 disponsizione una nuova attualizazione {version}.",
|
||||
"subscribers.advancedQuery": "Avanzate",
|
||||
|
|
65
i18n/ml.json
65
i18n/ml.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "ml",
|
||||
"_.name": "മലയാളം (ml)",
|
||||
"admin.errorMarshallingConfig": "അഭ്യർത്ഥന ക്രമീകരിയ്ക്കുന്നതിൽ പരാജയപ്പെട്ടു: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Add alternate plain text message",
|
||||
"campaigns.cantUpdate": "ഇപ്പോൾ നടന്നുകൊണ്ടിരിയ്ക്കുന്നതോ, അവസാനിച്ചതോ ആയ ക്യാമ്പേയ്ൻ പുതുക്കാനാകില്ല.",
|
||||
"campaigns.clicks": "ക്ലീക്കുകൾ",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "അടയ്ക്കുക",
|
||||
"globals.buttons.continue": "തുടരുക",
|
||||
"globals.buttons.delete": "നീക്കം ചെയ്യുക",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "തിരുത്തുക",
|
||||
"globals.buttons.enabled": "പ്രവർത്തനക്ഷമാക്കി",
|
||||
"globals.buttons.learnMore": "കൂടുതൽ അറിയുക",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "താങ്കൾക്ക് തീർച്ചയാണോ?",
|
||||
"globals.messages.created": "\"{name}\" നിർമ്മിച്ചു",
|
||||
"globals.messages.deleted": "\"{name}\" നീക്കം ചെയ്തു",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "ഇവിടൊന്നുമില്ല",
|
||||
"globals.messages.errorCreating": "{name} നിർമ്മിക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
|
||||
"globals.messages.errorDeleting": "{name} നീക്കം ചെയ്യുന്നതിൽ പിശകുണ്ടായി: {error}",
|
||||
"globals.messages.errorFetching": "{name} കൊണ്ടുവരുന്നതിൽ പിശകുണ്ടായി: {error}",
|
||||
"globals.messages.errorUUID": "യുയുഐഡി ഉണ്ടാക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
|
||||
"globals.messages.errorUpdating": "{name} പുതുക്കുന്നതിൽ പിശകുണ്ടായി: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ഐഡി അസാധുവാണ്",
|
||||
"globals.messages.invalidUUID": "യുയുഐഡി അസാധുവാണ്",
|
||||
"globals.messages.notFound": "{name} കണ്ടെത്തിയില്ല",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "ജൂലൈ",
|
||||
"globals.months.8": "ഓഗസ്റ്റ്",
|
||||
"globals.months.9": "സെപ്റ്റംബർ",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "ക്യാമ്പേയ്ൻ | ക്യാമ്പേയ്നുകൾ",
|
||||
"globals.terms.campaigns": "ക്യാമ്പേയ്നുകൾ",
|
||||
"globals.terms.dashboard": "ഡാഷ്ബോഡ്",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "നിങ്ങൾ വരിക്കാരനല്ലാതായി",
|
||||
"public.unsubbedTitle": "വരിക്കാരനല്ലാതാകുക",
|
||||
"public.unsubscribeTitle": "മെയിലിങ് ലിസ്റ്റിന്റെ വരിക്കാരനല്ലാതാകുക",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
|
||||
"settings.duplicateMessengerName": "ഒരേ പേരിൽ ഒന്നിലധികം സന്ദശവാഹകർ: {name}",
|
||||
"settings.errorEncoding": "ക്രമീകരണം എൻകോഡ് ചെയ്യുന്നതിൽ തടസം നേരിട്ടു: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "റൂട്ട് യൂ. ആർ. എൽ",
|
||||
"settings.general.rootURLHelp": "ഇൻസ്റ്റാളേഷന്റെ പൊതു യൂ. ആർ. എൽ (അവസാനത്തെ സ്ലാഷ് ആവശ്യമില്ല).",
|
||||
"settings.invalidMessengerName": "സന്ദേശവാഹകന്റെ പേര് അസാധുവാണ്",
|
||||
"settings.mailserver.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ",
|
||||
"settings.mailserver.host": "ഹോസ്റ്റ്",
|
||||
"settings.mailserver.hostHelp": "എസ്. എം. ടീ. പി സേർവ്വറിന്റെ വിലാസം.",
|
||||
"settings.mailserver.idleTimeout": "നിഷ്ക്രിയതാ സമയപരിധി",
|
||||
"settings.mailserver.idleTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
|
||||
"settings.mailserver.maxConns": "പരമാവധി കണക്ഷനുകൾ",
|
||||
"settings.mailserver.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
|
||||
"settings.mailserver.password": "രഹസ്യ വാക്ക്",
|
||||
"settings.mailserver.passwordHelp": "മാറ്റം വരുത്താൻ എന്റർ കീ അമർത്തുക",
|
||||
"settings.mailserver.port": "പോർട്ട്",
|
||||
"settings.mailserver.portHelp": "എസ്. എം. ടീ. പി സേർവറിന്റെ പോർട്ട്.",
|
||||
"settings.mailserver.skipTLS": "TLS പരിശോധന ഒഴിവാക്കുക",
|
||||
"settings.mailserver.skipTLSHelp": "TLS സർട്ടിഫിക്കേറ്റിന്റെ ഹോസ്റ്റ്നേയിം പരിശോധന ഒഴിവാക്കുക.",
|
||||
"settings.mailserver.tls": "ടിഎൽഎസ്",
|
||||
"settings.mailserver.tlsHelp": "STARTTLS പ്രവർത്തനക്ഷമമാക്കുക.",
|
||||
"settings.mailserver.username": "ഉപഭോക്തൃ നാമം",
|
||||
"settings.mailserver.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി",
|
||||
"settings.mailserver.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
|
||||
"settings.media.provider": "ദാതാവ്",
|
||||
"settings.media.s3.bucket": "ബക്കറ്റ്",
|
||||
"settings.media.s3.bucketPath": "ബക്കറ്റിലേക്കുള്ള പാത്ത്",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.",
|
||||
"settings.privacy.name": "സ്വകാര്യത",
|
||||
"settings.restart": "Restart",
|
||||
"settings.smtp.authProtocol": "പ്രാമാണീകരണ പ്രോട്ടോക്കോൾ",
|
||||
"settings.smtp.customHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ",
|
||||
"settings.smtp.customHeadersHelp": "ഈ സേർവറിൽ നിന്നും അയക്കുന്ന എല്ലാ ഈ-മെയിലിലും ഉണ്ടാകേണ്ട ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ. ഉദാഹരണം: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "പ്രവർത്തനക്ഷമമാക്കി",
|
||||
"settings.smtp.heloHost": "HELO ഹോസ്റ്റ് നേയിം",
|
||||
"settings.smtp.heloHostHelp": "ഐച്ഛികമാണ്. ചില എസ്. എം. ടീ. പി സേർവ്വറുകൾക്ക് ഹോസ്റ്റ് നേയിമിൽ FQDN വേണ്ടിവരാം. HELLO യ്ക്ക് `localhost` ഉപയോഗിക്കും. ഹോസ്റ്റ് നേയിം ഇഷ്ടാനുസൃതമാക്കാൻ ഇത് സജ്ജമാക്കുക",
|
||||
"settings.smtp.host": "ഹോസ്റ്റ്",
|
||||
"settings.smtp.hostHelp": "എസ്. എം. ടീ. പി സേർവ്വറിന്റെ വിലാസം.",
|
||||
"settings.smtp.idleTimeout": "നിഷ്ക്രിയതാ സമയപരിധി",
|
||||
"settings.smtp.idleTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
|
||||
"settings.smtp.maxConns": "പരമാവധി കണക്ഷനുകൾ",
|
||||
"settings.smtp.maxConnsHelp": "എസ്. എം. ടീ. പി സേർവ്വറിലേയ്ക്കുള്ള പരമാവധി സമാന്തര കണക്ഷനുകൾ.",
|
||||
"settings.smtp.name": "എസ്. എം. ടീ. പി",
|
||||
"settings.smtp.password": "രഹസ്യ വാക്ക്",
|
||||
"settings.smtp.passwordHelp": "മാറ്റം വരുത്താൻ എന്റർ കീ അമർത്തുക",
|
||||
"settings.smtp.port": "പോർട്ട്",
|
||||
"settings.smtp.portHelp": "എസ്. എം. ടീ. പി സേർവറിന്റെ പോർട്ട്.",
|
||||
"settings.smtp.retries": "പുനഃശ്രമങ്ങൾ",
|
||||
"settings.smtp.retriesHelp": "സന്ദേശമയ്ക്കുന്നത് പരാജയപ്പെട്ടാൽ എത്ര തവണ വീണ്ടും ശ്രമിക്കണം.",
|
||||
"settings.smtp.setCustomHeaders": "ഇഷ്ടാനുസൃത തലക്കെട്ടുകൾ നൽകുക",
|
||||
"settings.smtp.skipTLS": "TLS പരിശോധന ഒഴിവാക്കുക",
|
||||
"settings.smtp.skipTLSHelp": "TLS സർട്ടിഫിക്കേറ്റിന്റെ ഹോസ്റ്റ്നേയിം പരിശോധന ഒഴിവാക്കുക.",
|
||||
"settings.smtp.tls": "ടിഎൽഎസ്",
|
||||
"settings.smtp.tlsHelp": "STARTTLS പ്രവർത്തനക്ഷമമാക്കുക.",
|
||||
"settings.smtp.username": "ഉപഭോക്തൃ നാമം",
|
||||
"settings.smtp.waitTimeout": "കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി",
|
||||
"settings.smtp.waitTimeoutHelp": "പൂളിൽ നിന്നും കണക്ഷൻ വിച്ഛേദിയ്ക്കുന്നതിനുമുമ്പ് പുതിയ പ്രവർത്തനത്തിനായി കാത്തുനിൽക്കുന്നതിനുള്ള സമയപരിധി(s സെക്കന്റിന്, m മിനുട്ടിന്).",
|
||||
"settings.title": "ക്രമീകരണങ്ങൾ",
|
||||
"settings.updateAvailable": "A new update {version} is available.",
|
||||
"subscribers.advancedQuery": "വിപുലമായത്",
|
||||
|
|
65
i18n/pl.json
65
i18n/pl.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "pl",
|
||||
"_.name": "Polski (pl)",
|
||||
"admin.errorMarshallingConfig": "Błąd przerabiania konfiguracji: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Dodaj alternatywną wiadomość jako plain text",
|
||||
"campaigns.cantUpdate": "Nie można aktualizować aktywnej ani zakończonej kampanii",
|
||||
"campaigns.clicks": "Kliknięć",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Zamknij",
|
||||
"globals.buttons.continue": "Kontynuuj",
|
||||
"globals.buttons.delete": "Usuń",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Edytuj",
|
||||
"globals.buttons.enabled": "Włączone",
|
||||
"globals.buttons.learnMore": "Dowiedz się więcej",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Na pewno?",
|
||||
"globals.messages.created": "\"{name}\" utworzono",
|
||||
"globals.messages.deleted": "\"{name}\" usunięto",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Nic tutaj nie ma",
|
||||
"globals.messages.errorCreating": "Błąd podczas tworzenia {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Błąd podczas usuwania {name}: {error}",
|
||||
"globals.messages.errorFetching": "Błąd podczas pobierania {name}: {error}",
|
||||
"globals.messages.errorUUID": "Błąd podczas generowania UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Błąd podczas aktualizacji {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "Nieprawidłowy iD",
|
||||
"globals.messages.invalidUUID": "Nieprawidłowy UUID",
|
||||
"globals.messages.notFound": "{name} nie znaleziono",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Lip",
|
||||
"globals.months.8": "Sie",
|
||||
"globals.months.9": "Wrz",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Kampania | Kampanie",
|
||||
"globals.terms.campaigns": "Kampanie",
|
||||
"globals.terms.dashboard": "Przegląd",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Pomyślnie odsubskrybowano",
|
||||
"public.unsubbedTitle": "Odsubskrybowano",
|
||||
"public.unsubscribeTitle": "Wypisz się z listy mailingowej",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Upewnij się, że uruchomione kampanie są zapauzowane. Zrestartować?",
|
||||
"settings.duplicateMessengerName": "Powtórzona nazwa komunikatora: {name}",
|
||||
"settings.errorEncoding": "Błąd szyfrowania ustawień: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "Bazowy URL",
|
||||
"settings.general.rootURLHelp": "Publiczny URL instalacji (bez slasha na końcu)",
|
||||
"settings.invalidMessengerName": "Nieprawidłowa nazwa komunikatora.",
|
||||
"settings.mailserver.authProtocol": "Protokół autoryzacji",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "Adres serwera SMTP.",
|
||||
"settings.mailserver.idleTimeout": "Czas bezczynności",
|
||||
"settings.mailserver.idleTimeoutHelp": "Czas czekania na nową aktywność na połączeniu przed jej zamknięciem i usunięciem z puli (s dla sekud, m dla minut).",
|
||||
"settings.mailserver.maxConns": "Maksymalna liczba połączeń",
|
||||
"settings.mailserver.maxConnsHelp": "Maksymalna liczba jednoczesnych połączeń do serwera SMTP.",
|
||||
"settings.mailserver.password": "Hasło",
|
||||
"settings.mailserver.passwordHelp": "Wpisz w celu zmiany",
|
||||
"settings.mailserver.port": "Port",
|
||||
"settings.mailserver.portHelp": "Port serwera SMTP.",
|
||||
"settings.mailserver.skipTLS": "Pomiń weryfikację TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Pomiń sprawdzanie nazwy hosta dla certyfikatu TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Włącz STARTTLS.",
|
||||
"settings.mailserver.username": "Nazwa użytkownika",
|
||||
"settings.mailserver.waitTimeout": "Czas oczekiwania",
|
||||
"settings.mailserver.waitTimeoutHelp": "Czas czekania na nową aktywność na połączeniu przed jej zamknięciem i usunięciem z puli (s dla sekud, m dla minut).",
|
||||
"settings.media.provider": "Dostawca",
|
||||
"settings.media.s3.bucket": "Komora (Bucket)",
|
||||
"settings.media.s3.bucketPath": "Ścieżka komory (Bucket path)",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Dodaj nagłówki do wypisania się z subskrypcji. Niektóre programy pocztowe umożliwiają wypisanie się jednym kliknięciem.",
|
||||
"settings.privacy.name": "Prywatność",
|
||||
"settings.restart": "Restart",
|
||||
"settings.smtp.authProtocol": "Protokół autoryzacji",
|
||||
"settings.smtp.customHeaders": "Niestandardowe nagłówki",
|
||||
"settings.smtp.customHeadersHelp": "Opcjonalna lista nagłówków do zamieszczania w wiadomościach we wszystkich wiadomościach wysłanych z tego serwera. np: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Włączone",
|
||||
"settings.smtp.heloHost": "Nazwa hosta HELO",
|
||||
"settings.smtp.heloHostHelp": "Opcjonalne. Niektóre serwery SMTP wymagają FQDN w nazwie hosta. Domyślnie HELLO korzystają z `localhost`. Ustaw jeśli inny host powinien zostać użyty.",
|
||||
"settings.smtp.host": "Host",
|
||||
"settings.smtp.hostHelp": "Adres serwera SMTP.",
|
||||
"settings.smtp.idleTimeout": "Czas bezczynności",
|
||||
"settings.smtp.idleTimeoutHelp": "Czas czekania na nową aktywność na połączeniu przed jej zamknięciem i usunięciem z puli (s dla sekud, m dla minut).",
|
||||
"settings.smtp.maxConns": "Maksymalna liczba połączeń",
|
||||
"settings.smtp.maxConnsHelp": "Maksymalna liczba jednoczesnych połączeń do serwera SMTP.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Hasło",
|
||||
"settings.smtp.passwordHelp": "Wpisz w celu zmiany",
|
||||
"settings.smtp.port": "Port",
|
||||
"settings.smtp.portHelp": "Port serwera SMTP.",
|
||||
"settings.smtp.retries": "Ponowne próby",
|
||||
"settings.smtp.retriesHelp": "Liczba ponownych prób przy niepowodzeniu",
|
||||
"settings.smtp.setCustomHeaders": "Ustaw niestandardowe nagłówki",
|
||||
"settings.smtp.skipTLS": "Pomiń weryfikację TLS",
|
||||
"settings.smtp.skipTLSHelp": "Pomiń sprawdzanie nazwy hosta dla certyfikatu TLS.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Włącz STARTTLS.",
|
||||
"settings.smtp.username": "Nazwa użytkownika",
|
||||
"settings.smtp.waitTimeout": "Czas oczekiwania",
|
||||
"settings.smtp.waitTimeoutHelp": "Czas czekania na nową aktywność na połączeniu przed jej zamknięciem i usunięciem z puli (s dla sekud, m dla minut).",
|
||||
"settings.title": "Ustawienia",
|
||||
"settings.updateAvailable": "Nowa wersja {version} jest dostępna.",
|
||||
"subscribers.advancedQuery": "Zaawansowane",
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
"_.code": "pt-BR",
|
||||
"_.name": "Português Brasileiro (pt-BR)",
|
||||
"admin.errorMarshallingConfig": "Erro ao ler as configurações: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"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",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Fechar",
|
||||
"globals.buttons.continue": "Continuar",
|
||||
"globals.buttons.delete": "Excluir",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Editar",
|
||||
"globals.buttons.enabled": "Habilitado",
|
||||
"globals.buttons.learnMore": "Saiba mais",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Tem certeza?",
|
||||
"globals.messages.created": "\"{name}\" criado",
|
||||
"globals.messages.deleted": "\"{name}\" excluído",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"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.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ID inválido",
|
||||
"globals.messages.invalidUUID": "UUID inválido",
|
||||
"globals.messages.notFound": "{name} não encontrado",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Jul",
|
||||
"globals.months.8": "Ago",
|
||||
"globals.months.9": "Set",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campanha | Campanhas",
|
||||
"globals.terms.campaigns": "Campanhas",
|
||||
"globals.terms.dashboard": "Painel",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"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.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"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}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"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.mailserver.authProtocol": "Protocolo Autenticação",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "Endereço do servidor SMTP.",
|
||||
"settings.mailserver.idleTimeout": "Tempo limite ocioso",
|
||||
"settings.mailserver.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.mailserver.maxConns": "Máx. Conexões",
|
||||
"settings.mailserver.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor SMTP.",
|
||||
"settings.mailserver.password": "Senha",
|
||||
"settings.mailserver.passwordHelp": "Digite para alterar",
|
||||
"settings.mailserver.port": "Porta",
|
||||
"settings.mailserver.portHelp": "Porta do servidor SMTP.",
|
||||
"settings.mailserver.skipTLS": "Pular verificação de TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Pular verificação de hostname sobre o certificado TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Habilitar STARTTLS.",
|
||||
"settings.mailserver.username": "Usuário",
|
||||
"settings.mailserver.waitTimeout": "Tempo limite de espera",
|
||||
"settings.mailserver.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.media.provider": "Provedor",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Caminho do bucket",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"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",
|
||||
|
|
65
i18n/pt.json
65
i18n/pt.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "pt",
|
||||
"_.name": "Portuguese (pt)",
|
||||
"admin.errorMarshallingConfig": "Erro ao ler o config: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Adicionar mensagem alternativa em texto simples",
|
||||
"campaigns.cantUpdate": "Não é possível atualizar uma campanha em curso ou terminada.",
|
||||
"campaigns.clicks": "Cliques",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Fechar",
|
||||
"globals.buttons.continue": "Continuar",
|
||||
"globals.buttons.delete": "Eliminar",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Editar",
|
||||
"globals.buttons.enabled": "Ativo",
|
||||
"globals.buttons.learnMore": "Saber mais",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Tens a certeza?",
|
||||
"globals.messages.created": "\"{name}\" criado",
|
||||
"globals.messages.deleted": "\"{name}\" eliminado",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Não há nada aqui",
|
||||
"globals.messages.errorCreating": "Erro ao criar {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Erro ao eliminar {name}: {error}",
|
||||
"globals.messages.errorFetching": "Erro ao carregar {name}: {error}",
|
||||
"globals.messages.errorUUID": "Erro ao gerar UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Erro ao atualizar {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "ID inválido",
|
||||
"globals.messages.invalidUUID": "UUID inválido",
|
||||
"globals.messages.notFound": "{name} não encontrado",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Jul",
|
||||
"globals.months.8": "Ago",
|
||||
"globals.months.9": "Set",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Campanha | Campanhas",
|
||||
"globals.terms.campaigns": "Campanha",
|
||||
"globals.terms.dashboard": "Dashboard",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "A sua subscrição foi cancelada com sucesso.",
|
||||
"public.unsubbedTitle": "Subscrição cancelada",
|
||||
"public.unsubscribeTitle": "Cancelar subscrição da lista de emails",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
|
||||
"settings.duplicateMessengerName": "Nome duplicado do mensageiro: {name}",
|
||||
"settings.errorEncoding": "Erro de definições de codificação: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"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.mailserver.authProtocol": "Protocolo Autenticação",
|
||||
"settings.mailserver.host": "Host",
|
||||
"settings.mailserver.hostHelp": "O endereço host do servidor SMTP",
|
||||
"settings.mailserver.idleTimeout": "Tempo limite de inatividade",
|
||||
"settings.mailserver.idleTimeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
|
||||
"settings.mailserver.maxConns": "N. Max. Conexões",
|
||||
"settings.mailserver.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor SMTP.",
|
||||
"settings.mailserver.password": "Palavra-passe",
|
||||
"settings.mailserver.passwordHelp": "Escreve aqui para alterar",
|
||||
"settings.mailserver.port": "Porta",
|
||||
"settings.mailserver.portHelp": "Porta do servidor SMTP",
|
||||
"settings.mailserver.skipTLS": "Saltar verificação TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Saltar verificação do hostname no certificado TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Ativar STARTTLS.",
|
||||
"settings.mailserver.username": "Nome de utilizador",
|
||||
"settings.mailserver.waitTimeout": "Tempo limite de espera",
|
||||
"settings.mailserver.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.media.provider": "Fornecedor",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Caminho do bucket",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Incluir headers de cancelamento de subscrição que permite aos clientes de email permitir ao utilizadores cancelar a subscrição num único clique.",
|
||||
"settings.privacy.name": "Privacidade",
|
||||
"settings.restart": "Restart",
|
||||
"settings.smtp.authProtocol": "Protocolo Autenticação",
|
||||
"settings.smtp.customHeaders": "Headers customizados",
|
||||
"settings.smtp.customHeadersHelp": "Array opcional de headers de email a incluir em todas as mensagens enviadas deste servidor. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Ativo",
|
||||
"settings.smtp.heloHost": "Hostname HELO",
|
||||
"settings.smtp.heloHostHelp": "Opcional. Alguns servidores SMTP necessitam de um FQDN no hostname. Por padrão, HELLOs usam `localhost`. Coloca um hostname customizado se for necessario.",
|
||||
"settings.smtp.host": "Host",
|
||||
"settings.smtp.hostHelp": "O endereço host do servidor SMTP",
|
||||
"settings.smtp.idleTimeout": "Tempo limite de inatividade",
|
||||
"settings.smtp.idleTimeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
|
||||
"settings.smtp.maxConns": "N. Max. Conexões",
|
||||
"settings.smtp.maxConnsHelp": "Número máximo de conexões simultâneas ao servidor SMTP.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Palavra-passe",
|
||||
"settings.smtp.passwordHelp": "Escreve aqui para alterar",
|
||||
"settings.smtp.port": "Porta",
|
||||
"settings.smtp.portHelp": "Porta do servidor SMTP",
|
||||
"settings.smtp.retries": "Tentativas",
|
||||
"settings.smtp.retriesHelp": "Número de vezes para tentar novamente quando uma mensagem falha.",
|
||||
"settings.smtp.setCustomHeaders": "Colocar headers customizados",
|
||||
"settings.smtp.skipTLS": "Saltar verificação TLS",
|
||||
"settings.smtp.skipTLSHelp": "Saltar verificação do hostname no certificado TLS.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Ativar STARTTLS.",
|
||||
"settings.smtp.username": "Nome de utilizador",
|
||||
"settings.smtp.waitTimeout": "Tempo limite de espera",
|
||||
"settings.smtp.waitTimeoutHelp": "Tempo a esperar por nova atividade numa conexão antes de a fechar e removê-la da pool (s para segundo, m para minuto).",
|
||||
"settings.title": "Definições",
|
||||
"settings.updateAvailable": "A new update {version} is available.",
|
||||
"subscribers.advancedQuery": "Avançado",
|
||||
|
|
65
i18n/ru.json
65
i18n/ru.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "ru",
|
||||
"_.name": "Русский (ru)",
|
||||
"admin.errorMarshallingConfig": "Ошибка преобразования конфига: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Добавить альтернативное простое текстовое сообщение",
|
||||
"campaigns.cantUpdate": "Не возможно обновить запущенную или завершённую компанию.",
|
||||
"campaigns.clicks": "Клики",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Закрыть",
|
||||
"globals.buttons.continue": "Продолжить",
|
||||
"globals.buttons.delete": "Удалить",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Изменить",
|
||||
"globals.buttons.enabled": "Включено",
|
||||
"globals.buttons.learnMore": "Подпробней",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Уверены?",
|
||||
"globals.messages.created": "\"{name}\" создано",
|
||||
"globals.messages.deleted": "\"{name}\" удалено",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Ничего нет",
|
||||
"globals.messages.errorCreating": "Ошибка создания {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Ошибка удаления {name}: {error}",
|
||||
"globals.messages.errorFetching": "Ошибка получения {name}: {error}",
|
||||
"globals.messages.errorUUID": "Ошибка генерации UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Ошибка обновления {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "Неверный ID",
|
||||
"globals.messages.invalidUUID": "Неверный UUID",
|
||||
"globals.messages.notFound": "{name} не найдено",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Июл",
|
||||
"globals.months.8": "Авг",
|
||||
"globals.months.9": "Сен",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Компания | Компании",
|
||||
"globals.terms.campaigns": "Компании",
|
||||
"globals.terms.dashboard": "Панель",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Вы были отписаны.",
|
||||
"public.unsubbedTitle": "Отписано",
|
||||
"public.unsubscribeTitle": "Отписаться от списков рассылки",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Убедитесь, что запущенные кампании приостановлены. Запустить снова?",
|
||||
"settings.duplicateMessengerName": "Повторяющееся имя мессенджера: {name}",
|
||||
"settings.errorEncoding": "Error encoding settings: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "Базовый URL",
|
||||
"settings.general.rootURLHelp": "Публичный URL текущего портала (без конечного слэша).",
|
||||
"settings.invalidMessengerName": "Неверное имя мессенджера.",
|
||||
"settings.mailserver.authProtocol": "Протокол авторизации",
|
||||
"settings.mailserver.host": "Хост",
|
||||
"settings.mailserver.hostHelp": "Адрес сервера SMTP.",
|
||||
"settings.mailserver.idleTimeout": "Таймаут простоя",
|
||||
"settings.mailserver.idleTimeoutHelp": "Время ожидания новой активности в соединении перед тем, как закрыть и удалить его из пула (s, m соотвественно секунды и минуты).",
|
||||
"settings.mailserver.maxConns": "Максимальное количество соединений",
|
||||
"settings.mailserver.maxConnsHelp": "Максимальное количество одновременных соединений к серверу SMTP.",
|
||||
"settings.mailserver.password": "Пароль",
|
||||
"settings.mailserver.passwordHelp": "Для изменения введите",
|
||||
"settings.mailserver.port": "Порт",
|
||||
"settings.mailserver.portHelp": "Порт сервера SMTP.",
|
||||
"settings.mailserver.skipTLS": "Пропустить проверку TLS",
|
||||
"settings.mailserver.skipTLSHelp": "Не проверять имя хоста в сертификате TLS.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "Включить STARTTLS.",
|
||||
"settings.mailserver.username": "Имя пользователя",
|
||||
"settings.mailserver.waitTimeout": "Таймаут ожидания",
|
||||
"settings.mailserver.waitTimeoutHelp": "Время ожидания новой активности в соединении перед тем, как закрыть и удалить его из пула (s, m соттветственно секунды и минуты)",
|
||||
"settings.media.provider": "Провайдер",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Путь bucket",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "Включать заголовок отписки",
|
||||
"settings.privacy.name": "Конфиденциальност",
|
||||
"settings.restart": "Перезапустить",
|
||||
"settings.smtp.authProtocol": "Протокол авторизации",
|
||||
"settings.smtp.customHeaders": "Настраиваемые заголовки",
|
||||
"settings.smtp.customHeadersHelp": "Необязательный массив заголовков e-mail, которые будут включены во все письма, отправляемые с этого сервера. Например: [{\"X-Custom\": \"значение\"}, {\"X-Custom2\": \"значение\"}]",
|
||||
"settings.smtp.enabled": "Включено",
|
||||
"settings.smtp.heloHost": "Имя хоста HELO",
|
||||
"settings.smtp.heloHostHelp": "Необязательно. Некоторые серверы SMTP требуют FQDN в имени хоста. По умолчанию команды HELO идут с `localhost`. Укажите, если должно использоваться собственное имя хоста.",
|
||||
"settings.smtp.host": "Хост",
|
||||
"settings.smtp.hostHelp": "Адрес сервера SMTP.",
|
||||
"settings.smtp.idleTimeout": "Таймаут простоя",
|
||||
"settings.smtp.idleTimeoutHelp": "Время ожидания новой активности в соединении перед тем, как закрыть и удалить его из пула (s, m соотвественно секунды и минуты).",
|
||||
"settings.smtp.maxConns": "Максимальное количество соединений",
|
||||
"settings.smtp.maxConnsHelp": "Максимальное количество одновременных соединений к серверу SMTP.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Пароль",
|
||||
"settings.smtp.passwordHelp": "Для изменения введите",
|
||||
"settings.smtp.port": "Порт",
|
||||
"settings.smtp.portHelp": "Порт сервера SMTP.",
|
||||
"settings.smtp.retries": "Повторные попытки",
|
||||
"settings.smtp.retriesHelp": "Количество повторных попыток после ошибки отправки сообщения.",
|
||||
"settings.smtp.setCustomHeaders": "Установка настраиваемых заголовков",
|
||||
"settings.smtp.skipTLS": "Пропустить проверку TLS",
|
||||
"settings.smtp.skipTLSHelp": "Не проверять имя хоста в сертификате TLS.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "Включить STARTTLS.",
|
||||
"settings.smtp.username": "Имя пользователя",
|
||||
"settings.smtp.waitTimeout": "Таймаут ожидания",
|
||||
"settings.smtp.waitTimeoutHelp": "Время ожидания новой активности в соединении перед тем, как закрыть и удалить его из пула (s, m соттветственно секунды и минуты)",
|
||||
"settings.title": "Параметры",
|
||||
"settings.updateAvailable": "Доступна новая версия: {version}.",
|
||||
"subscribers.advancedQuery": "Дополнительно",
|
||||
|
|
65
i18n/tr.json
65
i18n/tr.json
|
@ -2,6 +2,9 @@
|
|||
"_.code": "tr",
|
||||
"_.name": "Turkish (tr)",
|
||||
"admin.errorMarshallingConfig": "Ayarlar ile ilgili hata: {error}",
|
||||
"bounces.source": "Source",
|
||||
"bounces.unknownService": "Unknown service.",
|
||||
"bounces.view": "View bounces",
|
||||
"campaigns.addAltText": "Alternatif düz metin ekleyin",
|
||||
"campaigns.cantUpdate": "Gönderilmekte olan veya gönderilmiş kampaynalar güncellenemez.",
|
||||
"campaigns.clicks": "Tıklama",
|
||||
|
@ -105,6 +108,7 @@
|
|||
"globals.buttons.close": "Kapat",
|
||||
"globals.buttons.continue": "Devam et",
|
||||
"globals.buttons.delete": "Sil",
|
||||
"globals.buttons.deleteAll": "Delete all",
|
||||
"globals.buttons.edit": "Değiştir",
|
||||
"globals.buttons.enabled": "Etkinleştirildi",
|
||||
"globals.buttons.learnMore": "Daha fazla öğren",
|
||||
|
@ -130,12 +134,15 @@
|
|||
"globals.messages.confirm": "Eminmisiniz?",
|
||||
"globals.messages.created": "\"{name}\" oluşturma",
|
||||
"globals.messages.deleted": "\"{name}\" silme",
|
||||
"globals.messages.deletedCount": "{name} ({num}) deleted",
|
||||
"globals.messages.emptyState": "Burası Boş",
|
||||
"globals.messages.errorCreating": "Hata oluşturma {name}: {error}",
|
||||
"globals.messages.errorDeleting": "Hata silme {name}: {error}",
|
||||
"globals.messages.errorFetching": "Hata çağırırken {name}: {error}",
|
||||
"globals.messages.errorUUID": "Hata oluştururken UUID: {error}",
|
||||
"globals.messages.errorUpdating": "Hata güncellerken {name}: {error}",
|
||||
"globals.messages.internalError": "Internal server error",
|
||||
"globals.messages.invalidData": "Invalid data",
|
||||
"globals.messages.invalidID": "Yanlış ID",
|
||||
"globals.messages.invalidUUID": "Yanlış UUID",
|
||||
"globals.messages.notFound": "{name} bulunamadı",
|
||||
|
@ -153,6 +160,8 @@
|
|||
"globals.months.7": "Tem",
|
||||
"globals.months.8": "Aug",
|
||||
"globals.months.9": "Eyl",
|
||||
"globals.terms.bounce": "Bounce | Bounces",
|
||||
"globals.terms.bounces": "Bounces",
|
||||
"globals.terms.campaign": "Kampanya | Kampanyalar",
|
||||
"globals.terms.campaigns": "Kampanyalar",
|
||||
"globals.terms.dashboard": "Yönetim Paneli",
|
||||
|
@ -274,6 +283,26 @@
|
|||
"public.unsubbedInfo": "Başarı ile üyeliğinizi bitirdiniz.",
|
||||
"public.unsubbedTitle": "Üyelik bitirildi.",
|
||||
"public.unsubscribeTitle": "e-posta listesi üyeliğini bitir",
|
||||
"settings.bounces.action": "Action",
|
||||
"settings.bounces.blocklist": "Blocklist",
|
||||
"settings.bounces.count": "Bounce count",
|
||||
"settings.bounces.countHelp": "Number of bounces per subscriber",
|
||||
"settings.bounces.delete": "Delete",
|
||||
"settings.bounces.enable": "Enable bounce processing",
|
||||
"settings.bounces.enableMailbox": "Enable bounce mailbox",
|
||||
"settings.bounces.enableSES": "Enable SES",
|
||||
"settings.bounces.enableSendgrid": "Enable SendGrid",
|
||||
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
|
||||
"settings.bounces.enabled": "Enabled",
|
||||
"settings.bounces.folder": "Folder",
|
||||
"settings.bounces.folderHelp": "Name of the IMAP folder to scan. Eg: Inbox.",
|
||||
"settings.bounces.invalidScanInterval": "Bounce scan interval should be minimum 1 minute.",
|
||||
"settings.bounces.name": "Bounces",
|
||||
"settings.bounces.scanInterval": "Scan interval",
|
||||
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
|
||||
"settings.bounces.sendgridKey": "SendGrid Key",
|
||||
"settings.bounces.type": "Type",
|
||||
"settings.bounces.username": "Username",
|
||||
"settings.confirmRestart": "Çalışan kampanyaların duraklatıldığından emin ol. Yeniden başlat?",
|
||||
"settings.duplicateMessengerName": "Çoklanmış messenger ismi: {name}",
|
||||
"settings.errorEncoding": "Hatalı kodlama ayarları: {error}",
|
||||
|
@ -295,6 +324,24 @@
|
|||
"settings.general.rootURL": "Kök URL",
|
||||
"settings.general.rootURLHelp": "Kurulumun genel URL'si (bölme çizgisi yok).",
|
||||
"settings.invalidMessengerName": "Geçersiz messenger adı.",
|
||||
"settings.mailserver.authProtocol": "Protokol",
|
||||
"settings.mailserver.host": "İstemci",
|
||||
"settings.mailserver.hostHelp": "SMTP sunucusu adresi.",
|
||||
"settings.mailserver.idleTimeout": "Idle süresi",
|
||||
"settings.mailserver.idleTimeoutHelp": "Bir bağlantıdaki yeni etkinliği kapatmadan ve havuzdan kaldırmadan önce bekleme süresi (s saniye, m dakika).",
|
||||
"settings.mailserver.maxConns": "Maks. bağ. say.",
|
||||
"settings.mailserver.maxConnsHelp": "SMTP sunucusuna aynı anda gönderilecek çoklu istek sayısı.",
|
||||
"settings.mailserver.password": "Parola",
|
||||
"settings.mailserver.passwordHelp": "Değiştirmek için giriniz",
|
||||
"settings.mailserver.port": "Port",
|
||||
"settings.mailserver.portHelp": "SMTP sunucusu port numarası.",
|
||||
"settings.mailserver.skipTLS": "TLS doğrulamasını atla",
|
||||
"settings.mailserver.skipTLSHelp": "TLS sertifikaları için sunucu adı doğrulamayı atla.",
|
||||
"settings.mailserver.tls": "TLS",
|
||||
"settings.mailserver.tlsHelp": "STARTTLS tanımla.",
|
||||
"settings.mailserver.username": "Kullanıcı adı",
|
||||
"settings.mailserver.waitTimeout": "Bekleme süresi aşımı",
|
||||
"settings.mailserver.waitTimeoutHelp": "Bir bağlantıdaki yeni etkinliği kapatmadan ve havuzdan kaldırmadan önce bekleme süresi (saniye için s, dakika için m). ",
|
||||
"settings.media.provider": "Sağlayıcı",
|
||||
"settings.media.s3.bucket": "Bucket",
|
||||
"settings.media.s3.bucketPath": "Bucket yolu",
|
||||
|
@ -355,33 +402,15 @@
|
|||
"settings.privacy.listUnsubHeaderHelp": "E-posta istemcilerinin kullanıcıların tek bir tıklamayla abonelikten çıkmalarına olanak tanıyan abonelik iptal başlıklarını ekleyin.",
|
||||
"settings.privacy.name": "Gizlilik",
|
||||
"settings.restart": "Yeniden başlat",
|
||||
"settings.smtp.authProtocol": "Protokol",
|
||||
"settings.smtp.customHeaders": "Özel başlık bilgisi",
|
||||
"settings.smtp.customHeadersHelp": "Bu sunucudan gönderilen tüm iletilere eklenecek isteğe bağlı e-posta başlıkları dizisi. Örnek: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
|
||||
"settings.smtp.enabled": "Etkinleştirildi",
|
||||
"settings.smtp.heloHost": "HELO İstemci adı",
|
||||
"settings.smtp.heloHostHelp": "Opsiyonel. Bazı SMTP sunucuları istemci adı olarak FQDN isterler. Varsayılan olarak, 'localhost' üzerine HELLO gönderilecektir. Farklı bir sunucu adı kullanılacaksa tanımlayın lütfen.",
|
||||
"settings.smtp.host": "İstemci",
|
||||
"settings.smtp.hostHelp": "SMTP sunucusu adresi.",
|
||||
"settings.smtp.idleTimeout": "Idle süresi",
|
||||
"settings.smtp.idleTimeoutHelp": "Bir bağlantıdaki yeni etkinliği kapatmadan ve havuzdan kaldırmadan önce bekleme süresi (s saniye, m dakika).",
|
||||
"settings.smtp.maxConns": "Maks. bağ. say.",
|
||||
"settings.smtp.maxConnsHelp": "SMTP sunucusuna aynı anda gönderilecek çoklu istek sayısı.",
|
||||
"settings.smtp.name": "SMTP",
|
||||
"settings.smtp.password": "Parola",
|
||||
"settings.smtp.passwordHelp": "Değiştirmek için giriniz",
|
||||
"settings.smtp.port": "Port",
|
||||
"settings.smtp.portHelp": "SMTP sunucusu port numarası.",
|
||||
"settings.smtp.retries": "Tekrarlama",
|
||||
"settings.smtp.retriesHelp": "Mesaj hata verdiğinde tekrar deneme sayısı.",
|
||||
"settings.smtp.setCustomHeaders": "Özel başlık tanımla",
|
||||
"settings.smtp.skipTLS": "TLS doğrulamasını atla",
|
||||
"settings.smtp.skipTLSHelp": "TLS sertifikaları için sunucu adı doğrulamayı atla.",
|
||||
"settings.smtp.tls": "TLS",
|
||||
"settings.smtp.tlsHelp": "STARTTLS tanımla.",
|
||||
"settings.smtp.username": "Kullanıcı adı",
|
||||
"settings.smtp.waitTimeout": "Bekleme süresi aşımı",
|
||||
"settings.smtp.waitTimeoutHelp": "Bir bağlantıdaki yeni etkinliği kapatmadan ve havuzdan kaldırmadan önce bekleme süresi (saniye için s, dakika için m). ",
|
||||
"settings.title": "Ayarlar",
|
||||
"settings.updateAvailable": "Yeni bir güncel sürüm {version} mevcuttur.",
|
||||
"subscribers.advancedQuery": "İleri düzey",
|
||||
|
|
148
internal/bounce/bounce.go
Normal file
148
internal/bounce/bounce.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
package bounce
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/listmonk/internal/bounce/mailbox"
|
||||
"github.com/knadh/listmonk/internal/bounce/webhooks"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
// subID is the identifying subscriber ID header to look for in
|
||||
// bounced e-mails.
|
||||
subID = "X-Listmonk-Subscriber"
|
||||
campID = "X-Listmonk-Campaign"
|
||||
)
|
||||
|
||||
// Mailbox represents a POP/IMAP mailbox client that can scan messages and pass
|
||||
// them to a given channel.
|
||||
type Mailbox interface {
|
||||
Scan(limit int, ch chan models.Bounce) error
|
||||
}
|
||||
|
||||
// Opt represents bounce processing options.
|
||||
type Opt struct {
|
||||
BounceCount int `json:"count"`
|
||||
BounceAction string `json:"action"`
|
||||
|
||||
MailboxEnabled bool `json:"mailbox_enabled"`
|
||||
MailboxType string `json:"mailbox_type"`
|
||||
Mailbox mailbox.Opt `json:"mailbox"`
|
||||
WebhooksEnabled bool `json:"webhooks_enabled"`
|
||||
SESEnabled bool `json:"ses_enabled"`
|
||||
SendgridEnabled bool `json:"sendgrid_enabled"`
|
||||
SendgridKey string `json:"sendgrid_key"`
|
||||
}
|
||||
|
||||
// Manager handles e-mail bounces.
|
||||
type Manager struct {
|
||||
queue chan models.Bounce
|
||||
mailbox Mailbox
|
||||
SES *webhooks.SES
|
||||
Sendgrid *webhooks.Sendgrid
|
||||
queries *Queries
|
||||
opt Opt
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// Queries contains the queries.
|
||||
type Queries struct {
|
||||
DB *sqlx.DB
|
||||
RecordQuery *sqlx.Stmt
|
||||
}
|
||||
|
||||
// New returns a new instance of the bounce manager.
|
||||
func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {
|
||||
m := &Manager{
|
||||
opt: opt,
|
||||
queries: q,
|
||||
queue: make(chan models.Bounce, 1000),
|
||||
log: lo,
|
||||
}
|
||||
|
||||
// Is there a mailbox?
|
||||
if opt.MailboxEnabled {
|
||||
switch opt.MailboxType {
|
||||
case "pop":
|
||||
m.mailbox = mailbox.NewPOP(opt.Mailbox)
|
||||
case "imap":
|
||||
default:
|
||||
return nil, errors.New("unknown bounce mailbox type")
|
||||
}
|
||||
}
|
||||
|
||||
if opt.WebhooksEnabled {
|
||||
if opt.SESEnabled {
|
||||
m.SES = webhooks.NewSES()
|
||||
}
|
||||
if opt.SendgridEnabled {
|
||||
sg, err := webhooks.NewSendgrid(opt.SendgridKey)
|
||||
if err != nil {
|
||||
lo.Printf("error initializing sendgrid webhooks: %v", err)
|
||||
} else {
|
||||
m.Sendgrid = sg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Run is a blocking function that listens for bounce events from webhooks and or mailboxes
|
||||
// and executes them on the DB.
|
||||
func (m *Manager) Run() {
|
||||
if m.opt.MailboxEnabled {
|
||||
go m.runMailboxScanner()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case b, ok := <-m.queue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := m.queries.RecordQuery.Exec(b.SubscriberUUID,
|
||||
b.Email,
|
||||
b.CampaignUUID,
|
||||
b.Type,
|
||||
b.Source,
|
||||
b.Meta,
|
||||
b.CreatedAt,
|
||||
m.opt.BounceCount,
|
||||
m.opt.BounceAction)
|
||||
if err != nil {
|
||||
// Ignore the error if it complained of no subscriber.
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "subscriber_id" {
|
||||
m.log.Printf("bounced subscriber (%s / %s) not found", b.SubscriberUUID, b.Email)
|
||||
continue
|
||||
}
|
||||
m.log.Printf("error recording bounce: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMailboxScanner runs a blocking loop that scans the mailbox at given intervals.
|
||||
func (m *Manager) runMailboxScanner() {
|
||||
for {
|
||||
if err := m.mailbox.Scan(1000, m.queue); err != nil {
|
||||
m.log.Printf("error scanning bounce mailbox: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(m.opt.Mailbox.ScanInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Record records a new bounce event given the subscriber's email or UUID.
|
||||
func (m *Manager) Record(b models.Bounce) error {
|
||||
select {
|
||||
case m.queue <- b:
|
||||
}
|
||||
return nil
|
||||
}
|
257
internal/bounce/gmail.bounce
Normal file
257
internal/bounce/gmail.bounce
Normal file
|
@ -0,0 +1,257 @@
|
|||
Delivered-To: kailash@zerodha.com
|
||||
Received: by 2002:a54:21c4:0:0:0:0:0 with SMTP id i4csp2867282eco;
|
||||
Sun, 23 May 2021 10:33:16 -0700 (PDT)
|
||||
X-Received: by 2002:a17:902:bb87:b029:ef:1ef:b4a5 with SMTP id m7-20020a170902bb87b02900ef01efb4a5mr21801783pls.28.1621791195832;
|
||||
Sun, 23 May 2021 10:33:15 -0700 (PDT)
|
||||
ARC-Seal: i=1; a=rsa-sha256; t=1621791195; cv=none;
|
||||
d=google.com; s=arc-20160816;
|
||||
b=xZixRTOHnpK7AKFJqDRGvXg8csiC/HDweapqiROpH9f3CBKOp2bNxesRYQAhF9dRER
|
||||
TcIdmsNBWmAsM3UCrKP1gsafEEhLa/egWet5tS7eRVNtrlf4xIr/Oyizzi/+vWTaYBaj
|
||||
SYS6ig0kEx1TIu23fhipMkjmqpba1CvekFt0Sujn51Wl/pCbxQLwXUUG+F2NlOZFnMNy
|
||||
GkxHgi+2lRqeowzPxMaUxat6yD0uym7V0TephJhTPTekZzIrXHQTd1T023qyvfUjdLU9
|
||||
HtXjkqJpJ2NIsHwhLTDqC860/dJMpKhMt6ekH5wK7ooXyXylOIeE9z9grYVVX7esWGgv
|
||||
M7Tw==
|
||||
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
|
||||
h=in-reply-to:references:subject:from:date:message-id:auto-submitted
|
||||
:to:dkim-signature;
|
||||
bh=N5pKD3v8h0to825sNHx1obZB8CFEVlHQ1rrjBil1NtM=;
|
||||
b=XNDLUAQC40qw4KX91RFgUOKWbgdsyQz86pvi7wENg/xBasGRJJjyZgBTYyA2e8XUN4
|
||||
3pbUZG68HsGI1bAE/t5HefjTmHRtoSh/nZzMVk+hoHbeFPtMcOo9sDWhcWnZjfFE2tzU
|
||||
lEDFV1M1NeKf8JcW+nm7Sq6haAv/M7C9q++kQxt0P6GnU17IOb5DyeUQ9SRVa1mTgjZt
|
||||
TuL94m2a7N6/KkHRrQCVyd1SZJR4+JDhFbdoScc0GXmu+aCt0DlznymAiLRX6SLB/sbx
|
||||
B4Aj9luupUg/9yzy0JCZ9qhjY3w+36mcz9EnIuA6TJP1AmBUFiVHVXjLTz8FKhKP/TcN
|
||||
Szrw==
|
||||
ARC-Authentication-Results: i=1; mx.google.com;
|
||||
dkim=pass header.i=@googlemail.com header.s=20161025 header.b="d/j1xe99";
|
||||
spf=pass (google.com: best guess record for domain of postmaster@mail-sor-f69.google.com designates 209.85.220.69 as permitted sender) smtp.helo=mail-sor-f69.google.com;
|
||||
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=googlemail.com
|
||||
Return-Path: <>
|
||||
Received: from mail-sor-f69.google.com (mail-sor-f69.google.com. [209.85.220.69])
|
||||
by mx.google.com with SMTPS id x9sor5739157pjh.37.2021.05.23.10.33.15
|
||||
for <kailash@zerodha.com>
|
||||
(Google Transport Security);
|
||||
Sun, 23 May 2021 10:33:15 -0700 (PDT)
|
||||
Received-SPF: pass (google.com: best guess record for domain of postmaster@mail-sor-f69.google.com designates 209.85.220.69 as permitted sender) client-ip=209.85.220.69;
|
||||
Authentication-Results: mx.google.com;
|
||||
dkim=pass header.i=@googlemail.com header.s=20161025 header.b="d/j1xe99";
|
||||
spf=pass (google.com: best guess record for domain of postmaster@mail-sor-f69.google.com designates 209.85.220.69 as permitted sender) smtp.helo=mail-sor-f69.google.com;
|
||||
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=googlemail.com
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=googlemail.com; s=20161025;
|
||||
h=to:auto-submitted:message-id:date:from:subject:references
|
||||
:in-reply-to;
|
||||
bh=N5pKD3v8h0to825sNHx1obZB8CFEVlHQ1rrjBil1NtM=;
|
||||
b=d/j1xe99m2/tEFKHFbaxWYQDypZPPXM4yaLLXfv78vQTkusHx6ezzjbbLDQThRi4Gp
|
||||
icphSZc7bxQqNICr6VHhbaFiywhQUwKRd4Atrv6pFO2pRJAPUX0U9NOK1ktRNsd5ePUA
|
||||
sIHIwA346yYp2mVsjzKaoAO6hmKG9wota8RYPKE2n3zHQPKdv+TzM9C/r1ddBrcyd92f
|
||||
tQeM0ySxXyPoQBvHmBhQyM02QcdB43GI7MqChsMM55FsyOAkzSyk2mpr/fR4WRFzqizB
|
||||
soso7v4Fk4SGSL/YWirMBEYfV4lMC02as8s+C/T3cDCDSLSYevk9TzQZkJCesxGg9v7Y
|
||||
oZsw==
|
||||
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=1e100.net; s=20161025;
|
||||
h=x-gm-message-state:to:auto-submitted:message-id:date:from:subject
|
||||
:references:in-reply-to;
|
||||
bh=N5pKD3v8h0to825sNHx1obZB8CFEVlHQ1rrjBil1NtM=;
|
||||
b=OrbtNPg5Os277o6RgpirMIAKmj2lOd2QCNP4GoWd587x5FG6IkDGS0FMlgqir4kd1m
|
||||
onrouDFZ2YIfwYjes6UwVc/jRtFEqweNfW148Wwtr0da3N++Q722KoKaIlivsi6tRic5
|
||||
IpLG2R/AdMRHyqKgc+32sY5bFpxA8dvV8QzeSjMDOiEW6MDW+5EPZRvlmrQHcs3gltgw
|
||||
fu24ZBvxO5p+SVA3fv4CHofWBx41eff1xDj3YeXBZC3UvycM3uDXPCccNSdGlFVdVj1k
|
||||
ShVVBaMYe7ogQaE7NuBI2fTg+2Gwj8A1qaZWkqNa5WMsV+IBGn5X+9hGv5+a2oRBuML2
|
||||
VYhw==
|
||||
X-Gm-Message-State: AOAM532KXW9W45VWVICwrMKyOmBmBlxCXVqvZLIyub147S7L81Y7Yop3
|
||||
eSoPXNYLWGr8Yfu3nAAx9bqznTMyYnI5amD+uBfPOw==
|
||||
X-Google-Smtp-Source: ABdhPJyoLDp5/aV12uOu5ZlEUItHwuaalyHiLdxJo/DQjtVQnyFAt10Oz/cnjtoGwe2hdi+YlKG+Ouy2NlxgqA9gI9fMRnAyaqs9KtM=
|
||||
X-Received: by 2002:a17:90b:30d0:: with SMTP id hi16mr20431437pjb.30.1621791195594;
|
||||
Sun, 23 May 2021 10:33:15 -0700 (PDT)
|
||||
Content-Type: multipart/report; boundary="0000000000001cf3b205c302b0a3"; report-type=delivery-status
|
||||
To: kailash@zerodha.com
|
||||
Received: by 2002:a17:90b:30d0:: with SMTP id hi16mr15665871pjb.30; Sun, 23
|
||||
May 2021 10:33:15 -0700 (PDT)
|
||||
Return-Path: <>
|
||||
Auto-Submitted: auto-replied
|
||||
Message-ID: <60aa91db.1c69fb81.218cf.a9fa.GMR@mx.google.com>
|
||||
Date: Sun, 23 May 2021 10:33:15 -0700 (PDT)
|
||||
From: Mail Delivery Subsystem <mailer-daemon@googlemail.com>
|
||||
Subject: Delivery Status Notification (Failure)
|
||||
References: <b417b131-afeb-1989-0d49-d6114141a784@zerodha.com>
|
||||
In-Reply-To: <b417b131-afeb-1989-0d49-d6114141a784@zerodha.com>
|
||||
X-Failed-Recipients: psaodp2apsdoad9@zerodha.com
|
||||
|
||||
--0000000000001cf3b205c302b0a3
|
||||
Content-Type: multipart/related; boundary="0000000000001cfdaa05c302b0a7"
|
||||
|
||||
--0000000000001cfdaa05c302b0a7
|
||||
Content-Type: multipart/alternative; boundary="0000000000001cfdb405c302b0a8"
|
||||
|
||||
--0000000000001cfdb405c302b0a8
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
|
||||
** Address not found **
|
||||
|
||||
Your message wasn't delivered to psaodp2apsdoad9@zerodha.com because the address couldn't be found, or is unable to receive mail.
|
||||
|
||||
Learn more here: https://support.google.com/mail/?p=NoSuchUser
|
||||
|
||||
The response was:
|
||||
|
||||
550 5.1.1 The email account that you tried to reach does not exist. Please try double-checking the recipient's email address for typos or unnecessary spaces. Learn more at https://support.google.com/mail/?p=NoSuchUser j11sor9289118pjs.32 - gsmtp
|
||||
|
||||
--0000000000001cfdb405c302b0a8
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {
|
||||
font-family:Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table cellpadding="0" cellspacing="0" class="email-wrapper" style="padding-top:32px;background-color:#ffffff;"><tbody>
|
||||
<tr><td>
|
||||
<table cellpadding=0 cellspacing=0><tbody>
|
||||
<tr><td style="max-width:560px;padding:24px 24px 32px;background-color:#fafafa;border:1px solid #e0e0e0;border-radius:2px">
|
||||
<img style="padding:0 24px 16px 0;float:left" width=72 height=72 alt="Error Icon" src="cid:icon.png">
|
||||
<table style="min-width:272px;padding-top:8px"><tbody>
|
||||
<tr><td><h2 style="font-size:20px;color:#212121;font-weight:bold;margin:0">
|
||||
Address not found
|
||||
</h2></td></tr>
|
||||
<tr><td style="padding-top:20px;color:#757575;font-size:16px;font-weight:normal;text-align:left">
|
||||
Your message wasn't delivered to <a style='color:#212121;text-decoration:none'><b>psaodp2apsdoad9@zerodha.com</b></a> because the address couldn't be found, or is unable to receive mail.
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:24px;color:#4285F4;font-size:14px;font-weight:bold;text-align:left">
|
||||
<a style="text-decoration:none" href="https://support.google.com/mail/?p=NoSuchUser">LEARN MORE</a>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
<tr style="border:none;background-color:#fff;font-size:12.8px;width:90%">
|
||||
<td align="left" style="padding:48px 10px">
|
||||
The response was:<br/>
|
||||
<p style="font-family:monospace">
|
||||
550 5.1.1 The email account that you tried to reach does not exist. Please try double-checking the recipient's email address for typos or unnecessary spaces. Learn more at https://support.google.com/mail/?p=NoSuchUser j11sor9289118pjs.32 - gsmtp
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--0000000000001cfdb405c302b0a8--
|
||||
--0000000000001cfdaa05c302b0a7
|
||||
Content-Type: image/png; name="icon.png"
|
||||
Content-Disposition: attachment; filename="icon.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <icon.png>
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAJAAAACQCAYAAADnRuK4AAAACXBIWXMAABYlAAAWJQFJUiTwAAAA
|
||||
GXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAABTdJREFUeNrsnD9sFEcUh5+PRMqZ
|
||||
yA0SPhAUQAQFUkyTgiBASARo6QApqVIkfdxGFJFSgGhJAUIiBaQB0ZIOKVCkwUgURjIg2fxL4kS+
|
||||
YDvkbC/388bi8N16Z4/d7J/5PsniuD3fyePP772ZeTsDQRAYQL/UGAJAIEAgQCBAIAAEAgQCBAIE
|
||||
AkAgyJT3Mv+Eq7vYK8mTE+MDRCAghQECAeRQA5V2ZOpmg5vDx3NPzRbmGRMEcmTrEbNNB8zWfRD+
|
||||
f/Efs2e3zCZvMjaksBg27TfbcuSNPEKP9ZyuAQKtHX2O9ncNgWC57umMPKvRNb0GEKgnLoUyxTQC
|
||||
rcns0/6uIRAs8/hGf9cQCJZpTpjdO2f25/03z+mxntM1eLtsZAgiUtX4JcaBCAQIBAgECARQ8CJa
|
||||
G5jab4J4pm4WZmO3OALVh802fIwcLkyPkcKAGggAgQCBAIEAgQCBABAIEAjKA/1AnahhbO5FdOOY
|
||||
VsrrDbPBYcYKgf5D2wLaV3p+22xh1u17tO3S+DTcvxvagUDeivPgx/a/95J/73w7Sj26Hn4pKo2M
|
||||
ehuV/KyBJM6d0f7k6RKx/R63vvL2tmf/ItDdM2ZTP6f7nkp9Y2fDx1v9akmpIU+KSCLVUghUQfSL
|
||||
zVKeTklbLxGoctw/nzC5rw8L5KRNbkpnKq6pgSqEClzNnFzY+XnYWrt6VpVk1vbwWvg+RKCKMOUw
|
||||
Q1LEOXA+/MX3mpJvGDHb265xtnzmFoUK1HaKQGlMtePYM+q2KKjXuaS1NJYIEKgI8jhEgqHt4cqy
|
||||
Ky53j3hyHz2bqSLp2o2LbJ7MxKovkGqXteoWpaOk96O9/yF/dF7NwlS36AuIQIBA5celQK4PIxBE
|
||||
4LLzrtoLgaALdSy6CJRkWQCBPGLsTHznomZ9nszUECgJ2ml3WWHe+QVFNPSQx6UdZNtxr9pbEShN
|
||||
eTTz8mQXHoHSlke7+Z+c9m6VGoHSkEfs/trLW3wQKApN1V3lGfnGu2Z6BFoLtYCs3GWBPAiUCLVh
|
||||
/HoaeRCoT9R873KLM/IgUBfapnCpe5AHgXry4pf412ihEHkQqCdxd5VqrcezhUIESsJMTJ+Pdthp
|
||||
Z0WgyNlXXPHc2Mc4IVAELl2Gnh8mhUDvCkfbIVAkcbf/aOoO3fMKhqAD3frTa4quwpn0hUDOkQhI
|
||||
YYBAgECAQAAU0QlYObl+5Ug8NcprZkZxjUCxRPVA6zmtEXHCBykskrhjgHXN09PoEcgFl4M4H11j
|
||||
nBAoApcj6ZoPGScEAgTKApcDoTw5sgWB+sGlz1n90IBAPdE6j1o21PfcC11jLagL1oFWRyGlKU3p
|
||||
OxcSJQ7NZAjkhHp/uG2HFAYIBAgECASAQIBAgECAQAAIBOkxEARBtp9wdVfAMOfIifEBIhCQwgCB
|
||||
ABAI0oV2jhxZ+nfBatuPZfgBCy0Eqqo8c01b+uu51XZvzOgDWoHNTGR+pCwpLEd5svuAZXlO2uEr
|
||||
PyEQ8hRWHgRCHmqg0sjTnLalv6crJQ8C/U8stqNO0I4+VZOHFIY8COS1PGL2ybd5yUMKK7s8zYmL
|
||||
dujyd3n+nESgcsvzZd4/KwIhDwIhT35QA6UyE1qyxZnfvJMHgdKS549JC1qvvJOHFIY8CFR5eV5O
|
||||
XimqPAhUdHnmfx+zgxdOFXkoqIGKKs/cswnb/8Oeog8HEai48nxUhiFBIORBIOShBioskkbySCLk
|
||||
IQIhDwIhj28p7FApR6b1qlEbHGpkO/rr6215vi/zH1r2x7tApSGFAQIBAgECAQIBIBAgECAQIBBA
|
||||
LK8FGADCTxYrr+EVJgAAAABJRU5ErkJggg==
|
||||
--0000000000001cfdaa05c302b0a7--
|
||||
--0000000000001cf3b205c302b0a3
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
Reporting-MTA: dns; googlemail.com
|
||||
Received-From-MTA: dns; kailash@zerodha.com
|
||||
Arrival-Date: Sun, 23 May 2021 10:33:14 -0700 (PDT)
|
||||
X-Original-Message-ID: <b417b131-afeb-1989-0d49-d6114141a784@zerodha.com>
|
||||
|
||||
Final-Recipient: rfc822; psaodp2apsdoad9@zerodha.com
|
||||
Action: failed
|
||||
Status: 5.1.1
|
||||
Diagnostic-Code: smtp; 550-5.1.1 The email account that you tried to reach does not exist. Please try
|
||||
550-5.1.1 double-checking the recipient's email address for typos or
|
||||
550-5.1.1 unnecessary spaces. Learn more at
|
||||
550 5.1.1 https://support.google.com/mail/?p=NoSuchUser j11sor9289118pjs.32 - gsmtp
|
||||
Last-Attempt-Date: Sun, 23 May 2021 10:33:15 -0700 (PDT)
|
||||
|
||||
--0000000000001cf3b205c302b0a3
|
||||
Content-Type: message/rfc822
|
||||
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=zerodha.com; s=google;
|
||||
h=to:from:subject:message-id:date:user-agent:mime-version
|
||||
:content-language:content-transfer-encoding;
|
||||
bh=lzgCIA1dMfq+1Zoi8Mtush+Jqj/mDmwC2uBHfa8ybDc=;
|
||||
b=OI+HpFZBSgaEofYQU9PrR5WymG/k8EXLOh0LJTaCLBt+fyv9xRqmIPJQwHaPaoV3o5
|
||||
a3YM9Lbq14BGK8ySHp+ffBcony8TiyqFEa61ostQvQyE21YayJg6EdacY/xHwpFlf8qP
|
||||
H7iBkJp1pMztZEyxwgu3dIKLkSicVMMlQVEVHpMhq6qaaypTc1VDQab4o9DB0/QPGmXV
|
||||
RJGbXn+UOLpY+sxxBrxYa65cszT9gbhIxXSB30SsRW3p7ZbtIEouaat7x4QIc0FCPfnc
|
||||
aQG8o0qFMQmaGbTvaGN4GdMPB/wBjfbhDqxG+uRTETQ75hcE7Pd1ymcivHjuwb8MxAgR
|
||||
3VhQ==
|
||||
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=1e100.net; s=20161025;
|
||||
h=x-gm-message-state:to:from:subject:message-id:date:user-agent
|
||||
:mime-version:content-language:content-transfer-encoding;
|
||||
bh=lzgCIA1dMfq+1Zoi8Mtush+Jqj/mDmwC2uBHfa8ybDc=;
|
||||
b=uZOZm6bHzCDq0BNBWANQbLYgwTFfmAe4jbJMeMyD118JH2ygj6RZgIoXdf5RsxL2DF
|
||||
Cj4FeXDRUPzl34SS9hshgD0fLJeLCxiKhRZZK7cuWrjelTY6A4zjNv5U3Z5+3EWk75p8
|
||||
tmnHxk2w86TDitiS/NH2MVPhjou20iwAW7KNlWLlvi0W7DO/1eK5zonfuAMMR8uUCV0F
|
||||
YGtz/WgHVnY//gFOhCFpGLxVBm+U8QGEigG8MLDUiGpc9lmvtwkMkpvnO0UZSeITYAlk
|
||||
xCN0Jk30pgBEq6CJ1m5TmqeAft7fv6258M/0TH5L1EVqAJQ+9wpvTvPg4FQQrrwz7/PK
|
||||
VOpg==
|
||||
X-Gm-Message-State: AOAM532L5lDl9xQIf19Fc0oe2hyHLx8Em+K7lIllieBybFHp01Zr2H3J
|
||||
wh64f6L6IgQ5tPBvmlRH8IctB4IiHdZquJoRp10FWHKjn7+L4jib5wsUDVnM/Uyjw44b326R08D
|
||||
fC4vnTb41a9b4AAoJSbKzgIB01Qy81YNUFx7qu9SSQBmaDxggTl/IQt6VsxdVeaY63SMd8rsnlm
|
||||
FmEFg=
|
||||
X-Google-Smtp-Source: ABdhPJzV4UdWvj19F/ju1ONILTdIJGjh0CEXGZRIXb0obPTeAAQoIvIxw87mW78rKDpFnLzRgUZhMw==
|
||||
X-Received: by 2002:a17:90b:30d0:: with SMTP id hi16mr20431400pjb.30.1621791194884;
|
||||
Sun, 23 May 2021 10:33:14 -0700 (PDT)
|
||||
Return-Path: <kailash@zerodha.com>
|
||||
Received: from [192.168.1.108] ([106.51.89.95])
|
||||
by smtp.gmail.com with ESMTPSA id n23sm6419900pgv.76.2021.05.23.10.33.13
|
||||
for <psaodp2apsdoad9@zerodha.com>
|
||||
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
|
||||
Sun, 23 May 2021 10:33:14 -0700 (PDT)
|
||||
To: psaodp2apsdoad9@zerodha.com
|
||||
From: Kailash Nadh <kailash@zerodha.com>
|
||||
Subject: test
|
||||
Message-ID: <b417b131-afeb-1989-0d49-d6114141a784@zerodha.com>
|
||||
Date: Sun, 23 May 2021 23:03:11 +0530
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101
|
||||
Thunderbird/78.10.1
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Language: en-US
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html>
|
||||
<head>
|
||||
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<p><font size="-1"><font face="Arial">this is a test message and can
|
||||
be ignored.</font></font><br>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--0000000000001cf3b205c302b0a3--
|
29
internal/bounce/mailbox/opt.go
Normal file
29
internal/bounce/mailbox/opt.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
package mailbox
|
||||
|
||||
import "time"
|
||||
|
||||
// Opt represents an e-mail POP/IMAP mailbox configuration.
|
||||
type Opt struct {
|
||||
// Host is the server's hostname.
|
||||
Host string `json:"host"`
|
||||
|
||||
// Port is the server port.
|
||||
Port int `json:"port"`
|
||||
|
||||
AuthProtocol string `json:"auth_protocol"`
|
||||
|
||||
// Username is the mail server login username.
|
||||
Username string `json:"username"`
|
||||
|
||||
// Password is the mail server login password.
|
||||
Password string `json:"password"`
|
||||
|
||||
// Folder is the name of the IMAP folder to scan for e-mails.
|
||||
Folder string `json:"folder"`
|
||||
|
||||
// Optional TLS settings.
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||
|
||||
ScanInterval time.Duration `json:"scan_interval"`
|
||||
}
|
119
internal/bounce/mailbox/pop.go
Normal file
119
internal/bounce/mailbox/pop.go
Normal file
|
@ -0,0 +1,119 @@
|
|||
package mailbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/go-pop3"
|
||||
"github.com/knadh/listmonk/models"
|
||||
)
|
||||
|
||||
// POP represents a POP mailbox.
|
||||
type POP struct {
|
||||
opt Opt
|
||||
client *pop3.Client
|
||||
}
|
||||
|
||||
// NewPOP returns a new instance of the POP mailbox client.
|
||||
func NewPOP(opt Opt) *POP {
|
||||
return &POP{
|
||||
opt: opt,
|
||||
client: pop3.New(pop3.Opt{
|
||||
Host: opt.Host,
|
||||
Port: opt.Port,
|
||||
TLSEnabled: opt.TLSEnabled,
|
||||
TLSSkipVerify: opt.TLSSkipVerify,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Scan scans the mailbox and pushes the downloaded messages into the given channel.
|
||||
// The messages that are downloaded are deleted from the server. If limit > 0,
|
||||
// all messages on the server are downloaded and deleted.
|
||||
func (p *POP) Scan(limit int, ch chan models.Bounce) error {
|
||||
c, err := p.client.NewConn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Quit()
|
||||
|
||||
// Authenticate.
|
||||
if p.opt.AuthProtocol != "none" {
|
||||
if err := c.Auth(p.opt.Username, p.opt.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Get the total number of messages on the server.
|
||||
count, _, err := c.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No messages.
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if limit > 0 && count > limit {
|
||||
count = limit
|
||||
}
|
||||
|
||||
// Download messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
// Download just one line of the body as the body is not required at all.
|
||||
m, err := c.Top(id, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
campUUID = m.Header.Get(models.EmailHeaderCampaignUUID)
|
||||
subUUID = m.Header.Get(models.EmailHeaderSubscriberUUID)
|
||||
date, _ = time.Parse("Mon, 02 Jan 2006 15:04:05 -0700", m.Header.Get("Date"))
|
||||
)
|
||||
|
||||
if campUUID == "" || subUUID == "" {
|
||||
continue
|
||||
}
|
||||
if date.IsZero() {
|
||||
date = time.Now()
|
||||
}
|
||||
|
||||
// Additional bounce e-mail metadata.
|
||||
meta, _ := json.Marshal(struct {
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
MessageID string `json:"message_id"`
|
||||
DeliveredTo string `json:"delivered_to"`
|
||||
Received []string `json:"received"`
|
||||
}{
|
||||
From: m.Header.Get("From"),
|
||||
Subject: m.Header.Get("Subject"),
|
||||
MessageID: m.Header.Get("Message-Id"),
|
||||
DeliveredTo: m.Header.Get("Delivered-To"),
|
||||
Received: m.Header.Map()["Received"],
|
||||
})
|
||||
|
||||
select {
|
||||
case ch <- models.Bounce{
|
||||
Type: "hard",
|
||||
CampaignUUID: m.Header.Get(models.EmailHeaderCampaignUUID),
|
||||
SubscriberUUID: m.Header.Get(models.EmailHeaderSubscriberUUID),
|
||||
Source: p.opt.Host,
|
||||
CreatedAt: date,
|
||||
Meta: json.RawMessage(meta),
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the downloaded messages.
|
||||
for id := 1; id <= count; id++ {
|
||||
if err := c.Dele(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
152
internal/bounce/proton.bounce
Normal file
152
internal/bounce/proton.bounce
Normal file
|
@ -0,0 +1,152 @@
|
|||
Content-Type: multipart/mixed;
|
||||
boundary=ea9649a4b8e544897b542b3a1b97e2d172a7efdd385fe08531c312b947091452
|
||||
References: <_GK6cYA-I3T01OCdH_MIi1whoJheEQ3--oTPqsatnNKqyJTNfcCFOQMJRqXyDtdOn_jPw7qb40E1F8QlDS_OAw==@protonmail.internalid>
|
||||
X-Pm-Date: Sun, 23 May 2021 17:12:42 +0000
|
||||
X-Pm-External-Id: <4Fp6NV4QXQz4wwcG@mail1.protonmail.ch>
|
||||
X-Pm-Internal-Id: _GK6cYA-I3T01OCdH_MIi1whoJheEQ3--oTPqsatnNKqyJTNfcCFOQMJRqXyDtdOn_jPw7qb40E1F8QlDS_OAw==
|
||||
To: <kailash@nadh.in>
|
||||
Reply-To: <MAILER-DAEMON@protonmail.com>
|
||||
From: <MAILER-DAEMON@protonmail.com>
|
||||
Subject: Undelivered Mail Returned to Sender
|
||||
Delivered-To: kailash@nadh.in
|
||||
X-Pm-Spam-Action: dunno
|
||||
X-Pm-Origin: internal
|
||||
X-Attached: email-1.3.eml
|
||||
Return-Path: <>
|
||||
Mime-Version: 1.0
|
||||
Authentication-Results: mailin027.protonmail.ch; dkim=none
|
||||
Authentication-Results: mailin027.protonmail.ch; spf=pass
|
||||
smtp.helo=mail-40136.protonmail.ch
|
||||
Authentication-Results: mailin027.protonmail.ch; dmarc=fail (p=quarantine
|
||||
dis=none) header.from=protonmail.com
|
||||
Received: by mail1.protonmail.ch (Postfix) id 4Fp6NV4QXQz4wwcG; Sun, 23 May
|
||||
2021 17:12:42 +0000 (UTC)
|
||||
Received: from mail-40136.protonmail.ch (mail-40136.protonmail.ch
|
||||
[185.70.40.136]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256
|
||||
bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)
|
||||
server-digest SHA256) (No client certificate requested) by
|
||||
mailin027.protonmail.ch (Postfix) with ESMTPS id 4Fp6NV73rqz9vNPR for
|
||||
<kailash@nadh.in>; Sun, 23 May 2021 17:12:42 +0000 (UTC)
|
||||
X-Pm-Spam: 0yeiAIic37iBOIJChpR3Y2bi4AiOiuHVZb8miiACL3cpJI6ZC2CIIMFGhwQGOmY2Y
|
||||
E4MWxDYMNUDzzQWOzMiA0sIHzCJIYIS6gsHImIzlNwX3iW0YOAiwiACL2cvNUicmziAOLACiwVmc 3b0JogIjujIIMBCCFlVQ1U18BCMCZ0RTOBiCllXYyczBFtcGyHBIbJ2hslmYXa5RlzIGwDQIIRHv wYDIVJuxtzIFy29YZoTg04CMTMwMxuXVuTALMBCSEZ1Q0XOl1TX0LElUR9VISByMkQ6wdvIEg2Qb cVmwhRXdGdvlAobipzMKX4Gg4EzWSN3440MCxC4MMYzgzlGbGdkVluIGsHdIL1mhzxWaGcrl5uZS dXRZX4Ggw4CMEISZVNRUMUlQXZ0Sg00T2UuVVyZGtGVIYlWszlGIGIvN1vbW5mxbIFGilNXdCZlB R1bmy2VcI1GhcxWaibwB92cmlWRacxluttFIWYslItZXlGFZb9Wu0F2WXXyBRvb3hm1bawWut92Y VXuxAuLTTCBMUZEfMVES1TQ9NTQVQFNIRojgMVESyTtBRjYXzGVaINFQyBiRWZvNRccmwi0bLAjg WNkUFRJ99NTlJ1BUSV0fgw0VWTpFNwbHlWtaIdGvgQ2b2cuVVyZGu1xcIEDuTBSOEUP9VERkS0ZX RVUNMlUQEIv5RlIGy2NcaBX0u9WaGI2FlsYWsWJYZ5ScgIibSf9B
|
||||
Date: Sun, 23 May 2021 17:12:42 +0000 (UTC)
|
||||
X-Original-To: kailash@nadh.in
|
||||
X-Pm-Content-Encryption: on-delivery
|
||||
Message-Id: <4Fp6NV4QXQz4wwcG@mail1.protonmail.ch>
|
||||
Auto-Submitted: auto-replied
|
||||
X-Pm-Spamscore: 0
|
||||
|
||||
--ea9649a4b8e544897b542b3a1b97e2d172a7efdd385fe08531c312b947091452
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
This is the mail system at ProtonMail.
|
||||
|
||||
I'm sorry to have to inform you that your message could not
|
||||
be delivered to one or more recipients. It's attached below.
|
||||
|
||||
For further assistance, please forward this email to support@protonmail.com=
|
||||
.
|
||||
|
||||
If you do so, you can delete your own text from the attached returned messa=
|
||||
ge.
|
||||
|
||||
|
||||
<osdktestpaostestapleps199aopp@zerodha.com>: host
|
||||
aspmx.l.google.com[172.217.218.26] said: 550-5.1.1 The email account th=
|
||||
at
|
||||
you tried to reach does not exist. Please try 550-5.1.1 double-checking=
|
||||
the
|
||||
recipient's email address for typos or 550-5.1.1 unnecessary spaces. Le=
|
||||
arn
|
||||
more at 550 5.1.1 https://support.google.com/mail/?p=3DNoSuchUser
|
||||
c95si12273060edf.464 - gsmtp (in reply to RCPT TO command)
|
||||
----------------------------------------------
|
||||
message/delivery-status
|
||||
----------------------------------------------
|
||||
Reporting-MTA: dns; mail1.protonmail.ch
|
||||
X-Postfix-Queue-ID: 4Fp6NT5QDfz4wwcW
|
||||
X-Postfix-Sender: rfc822; kailash@nadh.in
|
||||
Arrival-Date: Sun, 23 May 2021 17:12:41 +0000 (UTC)
|
||||
|
||||
Final-Recipient: rfc822; osdktestpaostestapleps199aopp@zerodha.com
|
||||
Original-Recipient: rfc822;osdktestpaostestapleps199aopp@zerodha.com
|
||||
Action: failed
|
||||
Status: 5.1.1
|
||||
Remote-MTA: dns; aspmx.l.google.com
|
||||
Diagnostic-Code: smtp; 550-5.1.1 The email account that you tried to reach =
|
||||
does
|
||||
not exist. Please try 550-5.1.1 double-checking the recipient's email
|
||||
address for typos or 550-5.1.1 unnecessary spaces. Learn more at 550 5.=
|
||||
1.1
|
||||
https://support.google.com/mail/?p=3DNoSuchUser c95si12273060edf.464 - =
|
||||
gsmtp
|
||||
----------------------------------------------
|
||||
message/rfc822
|
||||
----------------------------------------------
|
||||
Return-Path: <kailash@nadh.in>
|
||||
Date: Sun, 23 May 2021 17:12:34 +0000
|
||||
DKIM-Signature: v=3D1; a=3Drsa-sha256; c=3Drelaxed/relaxed; d=3Dnadh.in;
|
||||
s=3Dprotonmail2; t=3D1621789961;
|
||||
bh=3Dquadcf+F3w95Vv4EffkoWEgj0LG46W18oiyVW2rhqjk=3D;
|
||||
h=3DDate:To:From:Reply-To:Subject:From;
|
||||
b=3DJjlkyS8K1NigYGOKr6a+Citz/W/NUvCj52hkQu/U5iltmNH9EgWYLJ2gB56gb7WoQ
|
||||
KTbJidRExtT16u2FdSHlMFpJRYiurJ3S0ko6YGZYT+FUbYqPrKC9sGrX3iHR8g3h3E
|
||||
0UsWl9Ny/lylN8PA70tr3IHI0ZzSYP5njITZIyJP9QfHnXK/n9r418pLtnRXoovztX
|
||||
797taPQIUjiVXgGDSg9AcWsRHPHGE9y0otE1gG0Vzt7kMzY/RHLq65eRvPtncy9IUU
|
||||
7fSbsyi8qaBIbRoFFvkJTHgusTvbyFuPfnajd+Dpm6G3xLn26ny0ZXpDwPO/ZJOsVI
|
||||
BbcMLTMUBJToQ=3D=3D
|
||||
To: osdktestpaostestapleps199aopp@zerodha.com
|
||||
From: Kailash Nadh <kailash@nadh.in>
|
||||
Reply-To: Kailash Nadh <kailash@nadh.in>
|
||||
Subject: Hi, this is a test!
|
||||
Message-ID: <231fe26c-de5f-1fd7-b658-6452ae74149e@nadh.in>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=3Dutf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
X-Spam-Status: No, score=3D-1.2 required=3D10.0 tests=3DALL_TRUSTED,DKIM_SI=
|
||||
GNED,
|
||||
DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF shortcircuit=3Dno
|
||||
autolearn=3Ddisabled version=3D3.4.4
|
||||
X-Spam-Checker-Version: SpamAssassin 3.4.4 (2020-01-24) on
|
||||
mailout.protonmail.ch
|
||||
|
||||
Empty Message
|
||||
|
||||
|
||||
--ea9649a4b8e544897b542b3a1b97e2d172a7efdd385fe08531c312b947091452
|
||||
Content-Disposition: attachment; filename=email-1.3.eml
|
||||
Content-Type: message/rfc822; name=email-1.3.eml
|
||||
Content-Description: Undelivered Message
|
||||
X-Pm-Content-Encryption: on-delivery
|
||||
|
||||
Return-Path: <kailash@nadh.in>
|
||||
Date: Sun, 23 May 2021 17:12:34 +0000
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=nadh.in;
|
||||
s=protonmail2; t=1621789961;
|
||||
bh=quadcf+F3w95Vv4EffkoWEgj0LG46W18oiyVW2rhqjk=;
|
||||
h=Date:To:From:Reply-To:Subject:From;
|
||||
b=JjlkyS8K1NigYGOKr6a+Citz/W/NUvCj52hkQu/U5iltmNH9EgWYLJ2gB56gb7WoQ
|
||||
KTbJidRExtT16u2FdSHlMFpJRYiurJ3S0ko6YGZYT+FUbYqPrKC9sGrX3iHR8g3h3E
|
||||
0UsWl9Ny/lylN8PA70tr3IHI0ZzSYP5njITZIyJP9QfHnXK/n9r418pLtnRXoovztX
|
||||
797taPQIUjiVXgGDSg9AcWsRHPHGE9y0otE1gG0Vzt7kMzY/RHLq65eRvPtncy9IUU
|
||||
7fSbsyi8qaBIbRoFFvkJTHgusTvbyFuPfnajd+Dpm6G3xLn26ny0ZXpDwPO/ZJOsVI
|
||||
BbcMLTMUBJToQ==
|
||||
To: osdktestpaostestapleps199aopp@zerodha.com
|
||||
From: Kailash Nadh <kailash@nadh.in>
|
||||
Reply-To: Kailash Nadh <kailash@nadh.in>
|
||||
Subject: Hi, this is a test!
|
||||
Message-ID: <231fe26c-de5f-1fd7-b658-6452ae74149e@nadh.in>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
X-Spam-Status: No, score=-1.2 required=10.0 tests=ALL_TRUSTED,DKIM_SIGNED,
|
||||
DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF shortcircuit=no
|
||||
autolearn=disabled version=3.4.4
|
||||
X-Spam-Checker-Version: SpamAssassin 3.4.4 (2020-01-24) on
|
||||
mailout.protonmail.ch
|
||||
|
||||
Empty Message
|
||||
|
||||
|
||||
--ea9649a4b8e544897b542b3a1b97e2d172a7efdd385fe08531c312b947091452--
|
104
internal/bounce/webhooks/sendgrid.go
Normal file
104
internal/bounce/webhooks/sendgrid.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
)
|
||||
|
||||
type sendgridNotif struct {
|
||||
Email string `json:"email"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
// Sendgrid handles Sendgrid/SNS webhook notifications including confirming SNS topic subscription
|
||||
// requests and bounce notifications.
|
||||
type Sendgrid struct {
|
||||
pubKey *ecdsa.PublicKey
|
||||
}
|
||||
|
||||
// NewSendgrid returns a new Sendgrid instance.
|
||||
func NewSendgrid(key string) (*Sendgrid, error) {
|
||||
// Get the certificate from the key.
|
||||
sigB, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(sigB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Sendgrid{pubKey: pubKey.(*ecdsa.PublicKey)}, nil
|
||||
}
|
||||
|
||||
// ProcessBounce processes Sendgrid bounce notifications and returns one or more Bounce objects.
|
||||
func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) {
|
||||
if err := s.verifyNotif(sig, timestamp, b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var notifs []sendgridNotif
|
||||
if err := json.Unmarshal(b, ¬ifs); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err)
|
||||
}
|
||||
|
||||
out := make([]models.Bounce, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
if n.Event != "bounce" {
|
||||
continue
|
||||
}
|
||||
|
||||
tstamp := time.Unix(n.Timestamp, 0)
|
||||
b := models.Bounce{
|
||||
Email: strings.ToLower(n.Email),
|
||||
Type: models.BounceTypeHard,
|
||||
Meta: json.RawMessage(b),
|
||||
Source: "sendgrid",
|
||||
CreatedAt: tstamp,
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// verifyNotif verifies the signature on a notification payload.
|
||||
func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error {
|
||||
sigB, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ecdsaSig := struct {
|
||||
R *big.Int
|
||||
S *big.Int
|
||||
}{}
|
||||
|
||||
if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil {
|
||||
return fmt.Errorf("error asn1 unmarshal of signature: %v", err)
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(timestamp))
|
||||
h.Write(b)
|
||||
hash := h.Sum(nil)
|
||||
|
||||
if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) {
|
||||
return errors.New("invalid signature")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
249
internal/bounce/webhooks/ses.go
Normal file
249
internal/bounce/webhooks/ses.go
Normal file
|
@ -0,0 +1,249 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
)
|
||||
|
||||
// AWS signature/validation logic borrowed from @cavnit's contrib:
|
||||
// https://gist.github.com/cavnit/f4d63ba52b3aa05406c07dcbca2ca6cf
|
||||
|
||||
// https://sns.ap-southeast-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem
|
||||
var sesRegCertURL = regexp.MustCompile(`(?i)^https://sns\.[a-z0-9\-]+\.amazonaws\.com(\.cn)?/SimpleNotificationService\-[a-z0-9]+\.pem$`)
|
||||
|
||||
// sesNotif is an individual notification wrapper posted by SNS.
|
||||
type sesNotif struct {
|
||||
// Message may be a plaintext message or a stringified JSON payload based on the message type.
|
||||
// Four SES messages, this is the actual payload.
|
||||
Message string `json:"Message"`
|
||||
|
||||
MessageId string `json:"MessageId"`
|
||||
Signature string `json:"Signature"`
|
||||
SignatureVersion string `json:"SignatureVersion"`
|
||||
SigningCertURL string `json:"SigningCertURL"`
|
||||
Subject string `json:"Subject"`
|
||||
Timestamp string `json:"Timestamp"`
|
||||
Token string `json:"Token"`
|
||||
TopicArn string `json:"TopicArn"`
|
||||
Type string `json:"Type"`
|
||||
SubscribeURL string `json:"SubscribeURL"`
|
||||
UnsubscribeURL string `json:"UnsubscribeURL"`
|
||||
}
|
||||
|
||||
type sesTimestamp time.Time
|
||||
|
||||
type sesMail struct {
|
||||
NotifType string `json:"notificationType"`
|
||||
Bounce struct {
|
||||
BounceType string `json:"bounceType"`
|
||||
} `json:"bounce"`
|
||||
Mail struct {
|
||||
Timestamp sesTimestamp `json:"timestamp"`
|
||||
HeadersTruncated bool `json:"headersTruncated"`
|
||||
Destination []string `json:"destination"`
|
||||
Headers []map[string]string `json:"headers"`
|
||||
} `json:"mail"`
|
||||
}
|
||||
|
||||
// SES handles SES/SNS webhook notifications including confirming SNS topic subscription
|
||||
// requests and bounce notifications.
|
||||
type SES struct {
|
||||
certs map[string]*x509.Certificate
|
||||
}
|
||||
|
||||
// NewSES returns a new SES instance.
|
||||
func NewSES() *SES {
|
||||
return &SES{
|
||||
certs: make(map[string]*x509.Certificate),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessSubscription processes an SNS topic subscribe / unsubscribe notification
|
||||
// by parsing and verifying the payload and calling the subscribe / unsubscribe URL.
|
||||
func (s *SES) ProcessSubscription(b []byte) error {
|
||||
var n sesNotif
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return fmt.Errorf("error unmarshalling SNS notification: %v", err)
|
||||
}
|
||||
if err := s.verifyNotif(n); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make an HTTP request to the sub/unsub URL.
|
||||
u := n.SubscribeURL
|
||||
if n.Type == "UnsubscriptionConfirmation" {
|
||||
u = n.UnsubscribeURL
|
||||
}
|
||||
|
||||
resp, err := http.Get(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error requesting subscription URL: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("non 200 response on subscription URL: %v", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessBounce processes an SES bounce notification and returns a Bounce object.
|
||||
func (s *SES) ProcessBounce(b []byte) (models.Bounce, error) {
|
||||
var (
|
||||
bounce models.Bounce
|
||||
n sesNotif
|
||||
)
|
||||
if err := json.Unmarshal(b, &n); err != nil {
|
||||
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
|
||||
}
|
||||
if err := s.verifyNotif(n); err != nil {
|
||||
return bounce, err
|
||||
}
|
||||
|
||||
var m sesMail
|
||||
if err := json.Unmarshal([]byte(n.Message), &m); err != nil {
|
||||
return bounce, fmt.Errorf("error unmarshalling SES notification: %v", err)
|
||||
}
|
||||
|
||||
if len(m.Mail.Destination) == 0 {
|
||||
return bounce, errors.New("no destination e-mails found in SES notification")
|
||||
}
|
||||
|
||||
typ := "soft"
|
||||
if m.Bounce.BounceType == "Permanent" {
|
||||
typ = "hard"
|
||||
}
|
||||
|
||||
// Look for the campaign ID in headers.
|
||||
campUUID := ""
|
||||
if !m.Mail.HeadersTruncated {
|
||||
for _, h := range m.Mail.Headers {
|
||||
key, ok := h["name"]
|
||||
if !ok || key != models.EmailHeaderCampaignUUID {
|
||||
continue
|
||||
}
|
||||
|
||||
campUUID, ok = h["value"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return models.Bounce{
|
||||
Email: strings.ToLower(m.Mail.Destination[0]),
|
||||
CampaignUUID: campUUID,
|
||||
Type: typ,
|
||||
Source: "ses",
|
||||
Meta: json.RawMessage(n.Message),
|
||||
CreatedAt: time.Time(m.Mail.Timestamp),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SES) buildSignature(n sesNotif) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("Message" + "\n" + n.Message + "\n")
|
||||
b.WriteString("MessageId" + "\n" + n.MessageId + "\n")
|
||||
|
||||
if n.Subject != "" {
|
||||
b.WriteString("Subject" + "\n" + n.Subject + "\n")
|
||||
}
|
||||
if n.SubscribeURL != "" {
|
||||
b.WriteString("SubscribeURL" + "\n" + n.SubscribeURL + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("Timestamp" + "\n" + n.Timestamp + "\n")
|
||||
|
||||
if n.Token != "" {
|
||||
b.WriteString("Token" + "\n" + n.Token + "\n")
|
||||
}
|
||||
b.WriteString("TopicArn" + "\n" + n.TopicArn + "\n")
|
||||
b.WriteString("Type" + "\n" + n.Type + "\n")
|
||||
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// verifyNotif verifies the signature on a notification payload.
|
||||
func (s *SES) verifyNotif(n sesNotif) error {
|
||||
// Get the message signing certificate.
|
||||
cert, err := s.getCert(n.SigningCertURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting SNS cert: %v", err)
|
||||
}
|
||||
|
||||
sign, err := base64.StdEncoding.DecodeString(n.Signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return cert.CheckSignature(x509.SHA1WithRSA, s.buildSignature(n), sign)
|
||||
}
|
||||
|
||||
// getCert takes the SNS certificate URL and fetches it and caches it for the first time,
|
||||
// and returns the cached cert for subsequent calls.
|
||||
func (s *SES) getCert(certURL string) (*x509.Certificate, error) {
|
||||
// Ensure that the cert URL is Amazon's.
|
||||
u, err := url.Parse(certURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sesRegCertURL.MatchString(certURL) {
|
||||
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
|
||||
}
|
||||
|
||||
// Return if it's cached.
|
||||
if c, ok := s.certs[u.Path]; ok {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Fetch the certificate.
|
||||
resp, err := http.Get(certURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("invalid SNS certificate URL: %v", u.Host)
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p, _ := pem.Decode(body)
|
||||
if p == nil {
|
||||
return nil, errors.New("invalid PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(p.Bytes)
|
||||
|
||||
// Cache the cert in-memory.
|
||||
s.certs[u.Path] = cert
|
||||
|
||||
return cert, err
|
||||
}
|
||||
|
||||
func (st *sesTimestamp) UnmarshalJSON(b []byte) error {
|
||||
t, err := time.Parse("2006-01-02T15:04:05.999999999Z", strings.Trim(string(b), `"`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*st = sesTimestamp(t)
|
||||
return nil
|
||||
}
|
|
@ -21,27 +21,37 @@ const (
|
|||
// BaseTPL is the name of the base template.
|
||||
BaseTPL = "base"
|
||||
|
||||
BounceTypeBlocklist = "blocklist"
|
||||
BounceTypeDelete = "delete"
|
||||
|
||||
// ContentTpl is the name of the compiled message.
|
||||
ContentTpl = "content"
|
||||
|
||||
dummyUUID = "00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
// DataSource represents a data backend, such as a database,
|
||||
// Store represents a data backend, such as a database,
|
||||
// that provides subscriber and campaign records.
|
||||
type DataSource interface {
|
||||
type Store interface {
|
||||
NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error)
|
||||
NextSubscribers(campID, limit int) ([]models.Subscriber, error)
|
||||
GetCampaign(campID int) (*models.Campaign, error)
|
||||
UpdateCampaignStatus(campID int, status string) error
|
||||
CreateLink(url string) (string, error)
|
||||
|
||||
// RecordBounce records an external bounce event identified by
|
||||
// a user's UUID/e-mail and a campaign UUID.
|
||||
RecordBounce(b models.Bounce) (int64, int, error)
|
||||
|
||||
BlocklistSubscriber(id int64) error
|
||||
DeleteSubscriber(id int64) error
|
||||
}
|
||||
|
||||
// Manager handles the scheduling, processing, and queuing of campaigns
|
||||
// and message pushes.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
src DataSource
|
||||
store Store
|
||||
i18n *i18n.I18n
|
||||
messengers map[string]messenger.Messenger
|
||||
notifCB models.AdminNotifCallback
|
||||
|
@ -62,6 +72,7 @@ type Manager struct {
|
|||
campMsgErrorQueue chan msgError
|
||||
campMsgErrorCounts map[int]int
|
||||
msgQueue chan Message
|
||||
bounceQueue chan models.Bounce
|
||||
|
||||
// Sliding window keeps track of the total number of messages sent in a period
|
||||
// and on reaching the specified limit, waits until the window is over before
|
||||
|
@ -113,6 +124,8 @@ type Config struct {
|
|||
MessageURL string
|
||||
ViewTrackURL string
|
||||
UnsubHeader bool
|
||||
BounceCount int
|
||||
BounceAction string
|
||||
}
|
||||
|
||||
type msgError struct {
|
||||
|
@ -120,8 +133,10 @@ type msgError struct {
|
|||
err error
|
||||
}
|
||||
|
||||
var pushTimeout = time.Second * 3
|
||||
|
||||
// New returns a new instance of Mailer.
|
||||
func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, i *i18n.I18n, l *log.Logger) *Manager {
|
||||
func New(cfg Config, store Store, notifCB models.AdminNotifCallback, i *i18n.I18n, l *log.Logger) *Manager {
|
||||
if cfg.BatchSize < 1 {
|
||||
cfg.BatchSize = 1000
|
||||
}
|
||||
|
@ -134,7 +149,7 @@ func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, i *i18n.
|
|||
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
src: src,
|
||||
store: store,
|
||||
i18n: i,
|
||||
notifCB: notifCB,
|
||||
logger: l,
|
||||
|
@ -144,6 +159,7 @@ func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, i *i18n.
|
|||
subFetchQueue: make(chan *models.Campaign, cfg.Concurrency),
|
||||
campMsgQueue: make(chan CampaignMessage, cfg.Concurrency*2),
|
||||
msgQueue: make(chan Message, cfg.Concurrency),
|
||||
bounceQueue: make(chan models.Bounce, cfg.Concurrency),
|
||||
campMsgErrorQueue: make(chan msgError, cfg.MaxSendErrors),
|
||||
campMsgErrorCounts: make(map[int]int),
|
||||
slidingWindowStart: time.Now(),
|
||||
|
@ -184,7 +200,7 @@ func (m *Manager) AddMessenger(msg messenger.Messenger) error {
|
|||
// PushMessage pushes an arbitrary non-campaign Message to be sent out by the workers.
|
||||
// It times out if the queue is busy.
|
||||
func (m *Manager) PushMessage(msg Message) error {
|
||||
t := time.NewTicker(time.Second * 3)
|
||||
t := time.NewTicker(pushTimeout)
|
||||
defer t.Stop()
|
||||
|
||||
select {
|
||||
|
@ -199,7 +215,7 @@ func (m *Manager) PushMessage(msg Message) error {
|
|||
// PushCampaignMessage pushes a campaign messages to be sent out by the workers.
|
||||
// It times out if the queue is busy.
|
||||
func (m *Manager) PushCampaignMessage(msg CampaignMessage) error {
|
||||
t := time.NewTicker(time.Second * 3)
|
||||
t := time.NewTicker(pushTimeout)
|
||||
defer t.Stop()
|
||||
|
||||
select {
|
||||
|
@ -224,6 +240,20 @@ func (m *Manager) HasRunningCampaigns() bool {
|
|||
return len(m.camps) > 0
|
||||
}
|
||||
|
||||
// PushBounce records a bounce event.
|
||||
func (m *Manager) PushBounce(b models.Bounce) error {
|
||||
t := time.NewTicker(pushTimeout)
|
||||
defer t.Stop()
|
||||
|
||||
select {
|
||||
case m.bounceQueue <- b:
|
||||
case <-t.C:
|
||||
m.logger.Printf("bounce pushed timed out: %s / %s", b.SubscriberUUID, b.Email)
|
||||
return errors.New("bounce push timed out")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run is a blocking function (that should be invoked as a goroutine)
|
||||
// that scans the data source at regular intervals for pending campaigns,
|
||||
// and queues them for processing. The process queue fetches batches of
|
||||
|
@ -235,7 +265,7 @@ func (m *Manager) Run(tick time.Duration) {
|
|||
|
||||
// Spawn N message workers.
|
||||
for i := 0; i < m.cfg.Concurrency; i++ {
|
||||
go m.messageWorker()
|
||||
go m.worker()
|
||||
}
|
||||
|
||||
// Fetch the next set of subscribers for a campaign and process them.
|
||||
|
@ -262,9 +292,9 @@ func (m *Manager) Run(tick time.Duration) {
|
|||
}
|
||||
}
|
||||
|
||||
// messageWorker is a blocking function that listens to the message queue
|
||||
// and pushes out incoming messages on it to the messenger.
|
||||
func (m *Manager) messageWorker() {
|
||||
// worker is a blocking function that perpetually listents to events (message, bounce) on different
|
||||
// queues and processes them.
|
||||
func (m *Manager) worker() {
|
||||
// Counter to keep track of the message / sec rate limit.
|
||||
numMsg := 0
|
||||
for {
|
||||
|
@ -294,14 +324,18 @@ func (m *Manager) messageWorker() {
|
|||
Campaign: msg.Campaign,
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set(models.EmailHeaderCampaignUUID, msg.Campaign.UUID)
|
||||
h.Set(models.EmailHeaderSubscriberUUID, msg.Subscriber.UUID)
|
||||
|
||||
// Attach List-Unsubscribe headers?
|
||||
if m.cfg.UnsubHeader {
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
|
||||
out.Headers = h
|
||||
}
|
||||
|
||||
out.Headers = h
|
||||
|
||||
if err := m.messengers[msg.Campaign.Messenger].Push(out); err != nil {
|
||||
m.logger.Printf("error sending message in campaign %s: subscriber %s: %v",
|
||||
msg.Campaign.Name, msg.Subscriber.UUID, err)
|
||||
|
@ -331,6 +365,30 @@ func (m *Manager) messageWorker() {
|
|||
if err != nil {
|
||||
m.logger.Printf("error sending message '%s': %v", msg.Subject, err)
|
||||
}
|
||||
|
||||
// Bounce event.
|
||||
case b, ok := <-m.bounceQueue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
subID, count, err := m.store.RecordBounce(b)
|
||||
if err != nil {
|
||||
m.logger.Printf("error recording bounce %s / %s", b.SubscriberUUID, b.Email)
|
||||
}
|
||||
|
||||
if count >= m.cfg.BounceCount {
|
||||
switch m.cfg.BounceAction {
|
||||
case BounceTypeBlocklist:
|
||||
err = m.store.BlocklistSubscriber(subID)
|
||||
case BounceTypeDelete:
|
||||
err = m.store.DeleteSubscriber(subID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
m.logger.Printf("error executing bounce for subscriber: %s", b.SubscriberUUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -403,7 +461,7 @@ func (m *Manager) scanCampaigns(tick time.Duration) {
|
|||
select {
|
||||
// Periodically scan the data source for campaigns to process.
|
||||
case <-t.C:
|
||||
campaigns, err := m.src.NextCampaigns(m.getPendingCampaignIDs())
|
||||
campaigns, err := m.store.NextCampaigns(m.getPendingCampaignIDs())
|
||||
if err != nil {
|
||||
m.logger.Printf("error fetching campaigns: %v", err)
|
||||
continue
|
||||
|
@ -457,7 +515,7 @@ func (m *Manager) scanCampaigns(tick time.Duration) {
|
|||
func (m *Manager) addCampaign(c *models.Campaign) error {
|
||||
// Validate messenger.
|
||||
if _, ok := m.messengers[c.Messenger]; !ok {
|
||||
m.src.UpdateCampaignStatus(c.ID, models.CampaignStatusCancelled)
|
||||
m.store.UpdateCampaignStatus(c.ID, models.CampaignStatusCancelled)
|
||||
return fmt.Errorf("unknown messenger %s on campaign %s", c.Messenger, c.Name)
|
||||
}
|
||||
|
||||
|
@ -491,7 +549,7 @@ func (m *Manager) getPendingCampaignIDs() []int64 {
|
|||
// have been processed, or that a campaign has been paused or cancelled.
|
||||
func (m *Manager) nextSubscribers(c *models.Campaign, batchSize int) (bool, error) {
|
||||
// Fetch a batch of subscribers.
|
||||
subs, err := m.src.NextSubscribers(c.ID, batchSize)
|
||||
subs, err := m.store.NextSubscribers(c.ID, batchSize)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error fetching campaign subscribers (%s): %v", c.Name, err)
|
||||
}
|
||||
|
@ -566,7 +624,7 @@ func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Ca
|
|||
// A status has been passed. Change the campaign's status
|
||||
// without further checks.
|
||||
if status != "" {
|
||||
if err := m.src.UpdateCampaignStatus(c.ID, status); err != nil {
|
||||
if err := m.store.UpdateCampaignStatus(c.ID, status); err != nil {
|
||||
m.logger.Printf("error updating campaign (%s) status to %s: %v", c.Name, status, err)
|
||||
} else {
|
||||
m.logger.Printf("set campaign (%s) to %s", c.Name, status)
|
||||
|
@ -575,7 +633,7 @@ func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Ca
|
|||
}
|
||||
|
||||
// Fetch the up-to-date campaign status from the source.
|
||||
cm, err := m.src.GetCampaign(c.ID)
|
||||
cm, err := m.store.GetCampaign(c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -583,7 +641,7 @@ func (m *Manager) exhaustCampaign(c *models.Campaign, status string) (*models.Ca
|
|||
// If a running campaign has exhausted subscribers, it's finished.
|
||||
if cm.Status == models.CampaignStatusRunning {
|
||||
cm.Status = models.CampaignStatusFinished
|
||||
if err := m.src.UpdateCampaignStatus(c.ID, models.CampaignStatusFinished); err != nil {
|
||||
if err := m.store.UpdateCampaignStatus(c.ID, models.CampaignStatusFinished); err != nil {
|
||||
m.logger.Printf("error finishing campaign (%s): %v", c.Name, err)
|
||||
} else {
|
||||
m.logger.Printf("campaign (%s) finished", c.Name)
|
||||
|
@ -606,7 +664,7 @@ func (m *Manager) trackLink(url, campUUID, subUUID string) string {
|
|||
m.linksMut.RUnlock()
|
||||
|
||||
// Register link.
|
||||
uu, err := m.src.CreateLink(url)
|
||||
uu, err := m.store.CreateLink(url)
|
||||
if err != nil {
|
||||
m.logger.Printf("error registering tracking for link '%s': %v", url, err)
|
||||
|
||||
|
|
43
internal/migrations/v2.0.0.go
Normal file
43
internal/migrations/v2.0.0.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_0_0 performs the DB migrations for v.1.0.0.
|
||||
func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS bounces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
type bounce_type NOT NULL DEFAULT 'hard',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_sub_id ON bounces(subscriber_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_camp_id ON bounces(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bounces_source ON bounces(source);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('bounce.enabled', 'false'),
|
||||
('bounce.webhooks_enabled', 'false'),
|
||||
('bounce.count', '2'),
|
||||
('bounce.action', '"blocklist"'),
|
||||
('bounce.ses_enabled', 'false'),
|
||||
('bounce.sendgrid_enabled', 'false'),
|
||||
('bounce.sendgrid_key', '""'),
|
||||
('bounce.mailboxes', '[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@listmonk.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]')
|
||||
ON CONFLICT DO NOTHING;`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
|
@ -62,6 +63,13 @@ const (
|
|||
|
||||
// ContentTpl is the name of the compiled message.
|
||||
ContentTpl = "content"
|
||||
|
||||
// Headers attached to e-mails for bounce tracking.
|
||||
EmailHeaderSubscriberUUID = "X-Listmonk-Subscriber"
|
||||
EmailHeaderCampaignUUID = "X-Listmonk-Campaign"
|
||||
|
||||
BounceTypeHard = "hard"
|
||||
BounceTypeSoft = "soft"
|
||||
)
|
||||
|
||||
// regTplFunc represents contains a regular expression for wrapping and
|
||||
|
@ -201,6 +209,7 @@ type CampaignMeta struct {
|
|||
CampaignID int `db:"campaign_id" json:"-"`
|
||||
Views int `db:"views" json:"views"`
|
||||
Clicks int `db:"clicks" json:"clicks"`
|
||||
Bounces int `db:"bounces" json:"bounces"`
|
||||
|
||||
// This is a list of {list_id, name} pairs unlike Subscriber.Lists[]
|
||||
// because lists can be deleted after a campaign is finished, resulting
|
||||
|
@ -226,6 +235,27 @@ type Template struct {
|
|||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
}
|
||||
|
||||
// Bounce represents a single bounce event.
|
||||
type Bounce struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Source string `db:"source" json:"source"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
// One of these should be provided.
|
||||
Email string `db:"email" json:"email,omitempty"`
|
||||
SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
|
||||
SubscriberID int `db:"subscriber_id" json:"subscriber_id,omitempty"`
|
||||
|
||||
CampaignUUID string `db:"campaign_uuid" json:"campaign_uuid,omitempty"`
|
||||
Campaign *json.RawMessage `db:"campaign" json:"campaign"`
|
||||
|
||||
// Pseudofield for getting the total number of bounces
|
||||
// in searches and queries.
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// markdown is a global instance of Markdown parser and renderer.
|
||||
var markdown = goldmark.New(
|
||||
goldmark.WithRendererOptions(
|
||||
|
@ -310,6 +340,7 @@ func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error {
|
|||
camps[i].Lists = c.Lists
|
||||
camps[i].Views = c.Views
|
||||
camps[i].Clicks = c.Clicks
|
||||
camps[i].Bounces = c.Bounces
|
||||
}
|
||||
}
|
||||
|
||||
|
|
71
queries.sql
71
queries.sql
|
@ -451,15 +451,22 @@ clicks AS (
|
|||
SELECT campaign_id, COUNT(campaign_id) as num FROM link_clicks
|
||||
WHERE campaign_id = ANY($1)
|
||||
GROUP BY campaign_id
|
||||
),
|
||||
bounces AS (
|
||||
SELECT campaign_id, COUNT(campaign_id) as num FROM bounces
|
||||
WHERE campaign_id = ANY($1)
|
||||
GROUP BY campaign_id
|
||||
)
|
||||
SELECT id as campaign_id,
|
||||
COALESCE(v.num, 0) AS views,
|
||||
COALESCE(c.num, 0) AS clicks,
|
||||
COALESCE(b.num, 0) AS bounces,
|
||||
COALESCE(l.lists, '[]') AS lists
|
||||
FROM (SELECT id FROM UNNEST($1) AS id) x
|
||||
LEFT JOIN lists AS l ON (l.campaign_id = id)
|
||||
LEFT JOIN views AS v ON (v.campaign_id = id)
|
||||
LEFT JOIN clicks AS c ON (c.campaign_id = id)
|
||||
LEFT JOIN bounces AS b ON (b.campaign_id = id)
|
||||
ORDER BY ARRAY_POSITION($1, id);
|
||||
|
||||
-- name: get-campaign-for-preview
|
||||
|
@ -781,3 +788,67 @@ SELECT JSON_OBJECT_AGG(key, value) AS settings
|
|||
UPDATE settings AS s SET value = c.value
|
||||
-- For each key in the incoming JSON map, update the row with the key and its value.
|
||||
FROM(SELECT * FROM JSONB_EACH($1)) AS c(key, value) WHERE s.key = c.key;
|
||||
|
||||
-- name: record-bounce
|
||||
-- Insert a bounce and count the bounces for the subscriber and either unsubscribe them,
|
||||
WITH sub AS (
|
||||
SELECT id, status FROM subscribers WHERE CASE WHEN $1 != '' THEN uuid = $1::UUID ELSE email = $2 END
|
||||
),
|
||||
camp AS (
|
||||
SELECT id FROM campaigns WHERE $3 != '' AND uuid = $3::UUID
|
||||
),
|
||||
bounce AS (
|
||||
-- Record the bounce if it the subscriber is not already blocklisted;
|
||||
INSERT INTO bounces (subscriber_id, campaign_id, type, source, meta, created_at)
|
||||
SELECT (SELECT id FROM sub), (SELECT id FROM camp), $4, $5, $6, $7
|
||||
WHERE NOT EXISTS (SELECT 1 WHERE (SELECT status FROM sub) = 'blocklisted')
|
||||
),
|
||||
num AS (
|
||||
SELECT COUNT(*) AS num FROM bounces WHERE subscriber_id = (SELECT id FROM sub)
|
||||
),
|
||||
-- block1 and block2 will run when $8 = 'blocklist' and the number of bounces exceed $8.
|
||||
block1 AS (
|
||||
UPDATE subscribers SET status='blocklisted'
|
||||
WHERE $9 = 'blocklist' AND (SELECT num FROM num) >= $8 AND id = (SELECT id FROM sub) AND (SELECT status FROM sub) != 'blocklisted'
|
||||
),
|
||||
block2 AS (
|
||||
UPDATE subscriber_lists SET status='unsubscribed'
|
||||
WHERE $9 = 'blocklist' AND (SELECT num FROM num) >= $8 AND subscriber_id = (SELECT id FROM sub) AND (SELECT status FROM sub) != 'blocklisted'
|
||||
)
|
||||
-- This delete will only run when $9 = 'delete' and the number of bounces exceed $8.
|
||||
DELETE FROM subscribers
|
||||
WHERE $9 = 'delete' AND (SELECT num FROM num) >= $8 AND id = (SELECT id FROM sub);
|
||||
|
||||
-- name: query-bounces
|
||||
SELECT COUNT(*) OVER () AS total,
|
||||
bounces.id,
|
||||
bounces.type,
|
||||
bounces.source,
|
||||
bounces.meta,
|
||||
bounces.created_at,
|
||||
bounces.subscriber_id,
|
||||
subscribers.uuid AS subscriber_uuid,
|
||||
subscribers.email AS email,
|
||||
subscribers.email AS email,
|
||||
(
|
||||
CASE WHEN bounces.campaign_id IS NOT NULL
|
||||
THEN JSON_BUILD_OBJECT('id', bounces.campaign_id, 'name', campaigns.name)
|
||||
ELSE NULL END
|
||||
) AS campaign
|
||||
FROM bounces
|
||||
LEFT JOIN subscribers ON (subscribers.id = bounces.subscriber_id)
|
||||
LEFT JOIN campaigns ON (campaigns.id = bounces.campaign_id)
|
||||
WHERE ($1 = 0 OR bounces.id = $1)
|
||||
AND ($2 = 0 OR bounces.campaign_id = $2)
|
||||
AND ($3 = 0 OR bounces.subscriber_id = $3)
|
||||
AND ($4 = '' OR bounces.source = $4)
|
||||
ORDER BY %s %s OFFSET $5 LIMIT $6;
|
||||
|
||||
-- name: delete-bounces
|
||||
DELETE FROM bounces WHERE ARRAY_LENGTH($1::INT[], 1) IS NULL OR id = ANY($1);
|
||||
|
||||
-- name: delete-bounces-by-subscriber
|
||||
WITH sub AS (
|
||||
SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
|
||||
)
|
||||
DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub);
|
||||
|
|
27
schema.sql
27
schema.sql
|
@ -5,6 +5,7 @@ DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status
|
|||
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
|
||||
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
||||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
|
||||
-- subscribers
|
||||
DROP TABLE IF EXISTS subscribers CASCADE;
|
||||
|
@ -201,4 +202,28 @@ INSERT INTO settings (key, value) VALUES
|
|||
('smtp',
|
||||
'[{"enabled":true, "host":"smtp.yoursite.com","port":25,"auth_protocol":"cram","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":true,"tls_skip_verify":false,"email_headers":[]},
|
||||
{"enabled":false, "host":"smtp2.yoursite.com","port":587,"auth_protocol":"plain","username":"username","password":"password","hello_hostname":"","max_conns":10,"idle_timeout":"15s","wait_timeout":"5s","max_msg_retries":2,"tls_enabled":false,"tls_skip_verify":false,"email_headers":[]}]'),
|
||||
('messengers', '[]');
|
||||
('messengers', '[]'),
|
||||
('bounce.enabled', 'false'),
|
||||
('bounce.webhooks_enabled', 'false'),
|
||||
('bounce.count', '2'),
|
||||
('bounce.action', '"blocklist"'),
|
||||
('bounce.ses_enabled', 'false'),
|
||||
('bounce.sendgrid_enabled', 'false'),
|
||||
('bounce.sendgrid_key', '""'),
|
||||
('bounce.mailboxes',
|
||||
'[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@listmonk.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]');
|
||||
|
||||
-- bounces
|
||||
DROP TABLE IF EXISTS bounces CASCADE;
|
||||
CREATE TABLE bounces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
type bounce_type NOT NULL DEFAULT 'hard',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
DROP INDEX IF EXISTS idx_bounces_sub_id; CREATE INDEX idx_bounces_sub_id ON bounces(subscriber_id);
|
||||
DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bounces(campaign_id);
|
||||
DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
|
||||
|
|
Loading…
Reference in a new issue