Merge branch 'bounce'

This commit is contained in:
Kailash Nadh 2021-08-14 17:13:59 +05:30
commit 1be8c7d387
49 changed files with 2890 additions and 309 deletions

251
cmd/bounce.go Normal file
View 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
}

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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)

View file

@ -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)

View file

@ -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
}

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -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 });

View file

@ -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'; } /* '' */

View file

@ -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 {

View file

@ -7,6 +7,7 @@ export const models = Object.freeze({
campaigns: 'campaigns',
templates: 'templates',
media: 'media',
bounces: 'bounces',
settings: 'settings',
logs: 'logs',
});

View file

@ -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',

View 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>

View file

@ -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">

View file

@ -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') }} &rarr;</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;
});
});
},
},

View file

@ -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();
});

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "വിപുലമായത്",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "Дополнительно",

View file

@ -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
View 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
}

View 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&#39;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--

View 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"`
}

View 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
}

View 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--

View 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, &notifs); 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
}

View 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
}

View file

@ -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)

View 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
}

View file

@ -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
}
}

View file

@ -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);

View file

@ -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);