Add admin e-mail notifications.
- Add notifications for campaign state change - Add notifications for import state change Related changes. - Add a new 'templates' directory with HTML templates - Move the static campaign template as a .tpl file into it - Change Messenger.Push() to accept multiple recipients - Change exhaustCampaign()'s behaviour to pass metadata to admin emails
This commit is contained in:
parent
8a0a7a195e
commit
c24c19b120
16 changed files with 386 additions and 36 deletions
|
@ -477,7 +477,7 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
|
||||||
fmt.Sprintf("Error rendering message: %v", err))
|
fmt.Sprintf("Error rendering message: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.Messenger.Push(camp.FromEmail, sub.Email, camp.Subject, m.Body); err != nil {
|
if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,11 @@ root = "http://listmonk.mysite.com"
|
||||||
# The default 'from' e-mail for outgoing e-mail campaigns.
|
# The default 'from' e-mail for outgoing e-mail campaigns.
|
||||||
from_email = "listmonk <from@mail.com>"
|
from_email = "listmonk <from@mail.com>"
|
||||||
|
|
||||||
|
# List of e-mail addresses to which admin notifications such as
|
||||||
|
# import updates, campaign completion, failure etc. should be sent.
|
||||||
|
# To disable notifications, set an empty list, eg: notify_emails = []
|
||||||
|
notify_emails = ["admin1@mysite.com", "admin2@mysite.com"]
|
||||||
|
|
||||||
# Path to the uploads directory where media will be uploaded.
|
# Path to the uploads directory where media will be uploaded.
|
||||||
upload_path = "uploads"
|
upload_path = "uploads"
|
||||||
|
|
||||||
|
|
|
@ -110,8 +110,8 @@ func handleGetImportSubscribers(c echo.Context) error {
|
||||||
return c.JSON(http.StatusOK, okResp{s})
|
return c.JSON(http.StatusOK, okResp{s})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetImportSubscriberLogs returns import statistics.
|
// handleGetImportSubscriberStats returns import statistics.
|
||||||
func handleGetImportSubscriberLogs(c echo.Context) error {
|
func handleGetImportSubscriberStats(c echo.Context) error {
|
||||||
app := c.Get("app").(*App)
|
app := c.Get("app").(*App)
|
||||||
return c.JSON(http.StatusOK, okResp{string(app.Importer.GetLogs())})
|
return c.JSON(http.StatusOK, okResp{string(app.Importer.GetLogs())})
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,7 +129,7 @@ func install(app *App, qMap goyesql.Queries) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default template.
|
// Default template.
|
||||||
tplBody, err := ioutil.ReadFile("default-template.html")
|
tplBody, err := ioutil.ReadFile("templates/default.tpl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tplBody = []byte(tplTag)
|
tplBody = []byte(tplTag)
|
||||||
}
|
}
|
||||||
|
|
45
main.go
45
main.go
|
@ -20,14 +20,13 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var logger *log.Logger
|
|
||||||
|
|
||||||
type constants struct {
|
type constants struct {
|
||||||
AssetPath string `mapstructure:"asset_path"`
|
AssetPath string `mapstructure:"asset_path"`
|
||||||
RootURL string `mapstructure:"root"`
|
RootURL string `mapstructure:"root"`
|
||||||
UploadPath string `mapstructure:"upload_path"`
|
UploadPath string `mapstructure:"upload_path"`
|
||||||
UploadURI string `mapstructure:"upload_uri"`
|
UploadURI string `mapstructure:"upload_uri"`
|
||||||
FromEmail string `mapstructure:"from_email"`
|
FromEmail string `mapstructure:"from_email"`
|
||||||
|
NotifyEmails []string `mapstructure:"notify_emails"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// App contains the "global" components that are
|
// App contains the "global" components that are
|
||||||
|
@ -39,10 +38,12 @@ type App struct {
|
||||||
Importer *subimporter.Importer
|
Importer *subimporter.Importer
|
||||||
Runner *runner.Runner
|
Runner *runner.Runner
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
|
NotifTpls *template.Template
|
||||||
Messenger messenger.Messenger
|
Messenger messenger.Messenger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var logger *log.Logger
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
logger = log.New(os.Stdout, "SYS: ", log.Ldate|log.Ltime|log.Lshortfile)
|
logger = log.New(os.Stdout, "SYS: ", log.Ldate|log.Ltime|log.Lshortfile)
|
||||||
|
|
||||||
|
@ -94,7 +95,7 @@ func registerHandlers(e *echo.Echo) {
|
||||||
e.POST("/api/subscribers/lists", handleQuerySubscribersIntoLists)
|
e.POST("/api/subscribers/lists", handleQuerySubscribersIntoLists)
|
||||||
|
|
||||||
e.GET("/api/import/subscribers", handleGetImportSubscribers)
|
e.GET("/api/import/subscribers", handleGetImportSubscribers)
|
||||||
e.GET("/api/import/subscribers/logs", handleGetImportSubscriberLogs)
|
e.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
|
||||||
e.POST("/api/import/subscribers", handleImportSubscribers)
|
e.POST("/api/import/subscribers", handleImportSubscribers)
|
||||||
e.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
e.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
||||||
|
|
||||||
|
@ -158,7 +159,6 @@ func initMessengers(r *runner.Runner) messenger.Messenger {
|
||||||
|
|
||||||
var s messenger.Server
|
var s messenger.Server
|
||||||
viper.UnmarshalKey("smtp."+name, &s)
|
viper.UnmarshalKey("smtp."+name, &s)
|
||||||
|
|
||||||
s.Name = name
|
s.Name = name
|
||||||
s.SendTimeout = s.SendTimeout * time.Millisecond
|
s.SendTimeout = s.SendTimeout * time.Millisecond
|
||||||
srv = append(srv, s)
|
srv = append(srv, s)
|
||||||
|
@ -170,7 +170,6 @@ func initMessengers(r *runner.Runner) messenger.Messenger {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("error loading e-mail messenger: %v", err)
|
logger.Fatalf("error loading e-mail messenger: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.AddMessenger(msgr); err != nil {
|
if err := r.AddMessenger(msgr); err != nil {
|
||||||
logger.Printf("error registering messenger %s", err)
|
logger.Printf("error registering messenger %s", err)
|
||||||
}
|
}
|
||||||
|
@ -220,14 +219,32 @@ func main() {
|
||||||
if err := scanQueriesToStruct(q, qMap, db.Unsafe()); err != nil {
|
if err := scanQueriesToStruct(q, qMap, db.Unsafe()); err != nil {
|
||||||
logger.Fatalf("no SQL queries loaded: %v", err)
|
logger.Fatalf("no SQL queries loaded: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Queries = q
|
app.Queries = q
|
||||||
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt, q.BlacklistSubscriber.Stmt, db.DB)
|
|
||||||
|
// Importer.
|
||||||
|
importNotifCB := func(subject string, data map[string]interface{}) error {
|
||||||
|
return sendNotification(notifTplImport, subject, data, app)
|
||||||
|
}
|
||||||
|
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt,
|
||||||
|
q.BlacklistSubscriber.Stmt,
|
||||||
|
db.DB,
|
||||||
|
importNotifCB)
|
||||||
|
|
||||||
|
// System e-mail templates.
|
||||||
|
notifTpls, err := template.ParseGlob("templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("error loading system templates: %v", err)
|
||||||
|
}
|
||||||
|
app.NotifTpls = notifTpls
|
||||||
|
|
||||||
// Campaign daemon.
|
// Campaign daemon.
|
||||||
|
campNotifCB := func(subject string, data map[string]interface{}) error {
|
||||||
|
return sendNotification(notifTplCampaign, subject, data, app)
|
||||||
|
}
|
||||||
r := runner.New(runner.Config{
|
r := runner.New(runner.Config{
|
||||||
Concurrency: viper.GetInt("app.concurrency"),
|
Concurrency: viper.GetInt("app.concurrency"),
|
||||||
MaxSendErrors: viper.GetInt("app.max_send_errors"),
|
MaxSendErrors: viper.GetInt("app.max_send_errors"),
|
||||||
|
FromEmail: app.Constants.FromEmail,
|
||||||
|
|
||||||
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
||||||
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
|
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
|
||||||
|
@ -237,7 +254,7 @@ func main() {
|
||||||
|
|
||||||
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
|
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
|
||||||
ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL),
|
ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL),
|
||||||
}, newRunnerDB(q), logger)
|
}, newRunnerDB(q), campNotifCB, logger)
|
||||||
app.Runner = r
|
app.Runner = r
|
||||||
|
|
||||||
// Add messengers.
|
// Add messengers.
|
||||||
|
|
|
@ -66,7 +66,7 @@ func (e *emailer) Name() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push pushes a message to the server.
|
// Push pushes a message to the server.
|
||||||
func (e *emailer) Push(fromAddr, toAddr, subject string, m []byte) error {
|
func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byte) error {
|
||||||
var key string
|
var key string
|
||||||
|
|
||||||
// If there are more than one SMTP servers, send to a random
|
// If there are more than one SMTP servers, send to a random
|
||||||
|
@ -80,7 +80,7 @@ func (e *emailer) Push(fromAddr, toAddr, subject string, m []byte) error {
|
||||||
srv := e.servers[key]
|
srv := e.servers[key]
|
||||||
err := srv.mailer.Send(&email.Email{
|
err := srv.mailer.Send(&email.Email{
|
||||||
From: fromAddr,
|
From: fromAddr,
|
||||||
To: []string{toAddr},
|
To: toAddr,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
HTML: m,
|
HTML: m,
|
||||||
}, srv.SendTimeout)
|
}, srv.SendTimeout)
|
||||||
|
|
|
@ -5,6 +5,6 @@ package messenger
|
||||||
type Messenger interface {
|
type Messenger interface {
|
||||||
Name() string
|
Name() string
|
||||||
|
|
||||||
Push(fromAddr, toAddr, subject string, message []byte) error
|
Push(fromAddr string, toAddr []string, subject string, message []byte) error
|
||||||
Flush() error
|
Flush() error
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,10 @@ var (
|
||||||
regexpViewTagReplace = `{{ TrackView .Campaign.UUID .Subscriber.UUID }}`
|
regexpViewTagReplace = `{{ TrackView .Campaign.UUID .Subscriber.UUID }}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AdminNotifCallback is a callback function that's called
|
||||||
|
// when a campaign's status changes.
|
||||||
|
type AdminNotifCallback func(subject string, data map[string]interface{}) error
|
||||||
|
|
||||||
// Base holds common fields shared across models.
|
// Base holds common fields shared across models.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
ID int `db:"id" json:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
|
|
32
notifications.go
Normal file
32
notifications.go
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
notifTplImport = "import-status"
|
||||||
|
notifTplCampaign = "campaign-status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendNotification sends out an e-mail notification to admins.
|
||||||
|
func sendNotification(tpl, subject string, data map[string]interface{}, app *App) error {
|
||||||
|
data["RootURL"] = app.Constants.RootURL
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.Messenger.Push(app.Constants.FromEmail,
|
||||||
|
app.Constants.NotifyEmails,
|
||||||
|
subject,
|
||||||
|
b.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
111
public/static/logo.svg
Normal file
111
public/static/logo.svg
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="30.64039mm"
|
||||||
|
height="6.7046347mm"
|
||||||
|
viewBox="0 0 30.640391 6.7046347"
|
||||||
|
version="1.1"
|
||||||
|
id="svg8"
|
||||||
|
sodipodi:docname="logo.svg"
|
||||||
|
inkscape:version="0.92.3 (2405546, 2018-03-11)">
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="1"
|
||||||
|
inkscape:cx="109.96648"
|
||||||
|
inkscape:cy="13.945787"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:window-width="1853"
|
||||||
|
inkscape:window-height="1025"
|
||||||
|
inkscape:window-x="67"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-12.438455,-21.535559)">
|
||||||
|
<path
|
||||||
|
style="fill:#ffd42a;fill-opacity:1;stroke:none;stroke-width:1.43600929;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 15.310858,21.535559 a 2.8721479,2.8721479 0 0 0 -2.872403,2.872389 2.8721479,2.8721479 0 0 0 0.333807,1.339229 c 0.441927,-0.942916 1.401145,-1.594382 2.538596,-1.594382 1.137597,0 2.096714,0.651716 2.538577,1.594828 a 2.8721479,2.8721479 0 0 0 0.333358,-1.339675 2.8721479,2.8721479 0 0 0 -2.871935,-2.872389 z"
|
||||||
|
id="circle920"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96" />
|
||||||
|
<flowRoot
|
||||||
|
xml:space="preserve"
|
||||||
|
id="flowRoot935"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
|
||||||
|
transform="matrix(0.18971612,0,0,0.18971612,67.141498,76.054278)"><flowRegion
|
||||||
|
id="flowRegion937"><rect
|
||||||
|
id="rect939"
|
||||||
|
width="338"
|
||||||
|
height="181"
|
||||||
|
x="-374"
|
||||||
|
y="-425.36423" /></flowRegion><flowPara
|
||||||
|
id="flowPara941" /></flowRoot> <text
|
||||||
|
id="text874-8"
|
||||||
|
y="27.493284"
|
||||||
|
x="19.714029"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:5.92369604px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.03127443"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"><tspan
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:5.92369604px;font-family:'Avenir LT Std';-inkscape-font-specification:'Avenir LT Std Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.03127443"
|
||||||
|
y="27.493284"
|
||||||
|
x="19.714029"
|
||||||
|
id="tspan872-0"
|
||||||
|
sodipodi:role="line">listmonk</tspan></text>
|
||||||
|
<circle
|
||||||
|
r="2.1682308"
|
||||||
|
cy="25.693378"
|
||||||
|
cx="15.314515"
|
||||||
|
id="circle876-1"
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#7f2aff;stroke-width:0.75716889;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96" />
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path878-0"
|
||||||
|
d="m 15.314516,23.765261 a 2.1682305,2.6103294 0 0 0 -2.168147,2.610218 2.1682305,2.6103294 0 0 0 0.04998,0.542977 2.1682305,2.6103294 0 0 1 2.118166,-2.059418 2.1682305,2.6103294 0 0 1 2.118165,2.067255 2.1682305,2.6103294 0 0 0 0.04998,-0.550814 2.1682305,2.6103294 0 0 0 -2.168146,-2.610218 z"
|
||||||
|
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.83078319;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 5.2 KiB |
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -38,6 +39,7 @@ type Runner struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
src DataSource
|
src DataSource
|
||||||
messengers map[string]messenger.Messenger
|
messengers map[string]messenger.Messenger
|
||||||
|
notifCB models.AdminNotifCallback
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
||||||
// Campaigns that are currently running.
|
// Campaigns that are currently running.
|
||||||
|
@ -70,6 +72,7 @@ type Config struct {
|
||||||
Concurrency int
|
Concurrency int
|
||||||
MaxSendErrors int
|
MaxSendErrors int
|
||||||
RequeueOnError bool
|
RequeueOnError bool
|
||||||
|
FromEmail string
|
||||||
LinkTrackURL string
|
LinkTrackURL string
|
||||||
UnsubscribeURL string
|
UnsubscribeURL string
|
||||||
ViewTrackURL string
|
ViewTrackURL string
|
||||||
|
@ -81,10 +84,11 @@ type msgError struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new instance of Mailer.
|
// New returns a new instance of Mailer.
|
||||||
func New(cfg Config, src DataSource, l *log.Logger) *Runner {
|
func New(cfg Config, src DataSource, notifCB models.AdminNotifCallback, l *log.Logger) *Runner {
|
||||||
r := Runner{
|
r := Runner{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
src: src,
|
src: src,
|
||||||
|
notifCB: notifCB,
|
||||||
logger: l,
|
logger: l,
|
||||||
messengers: make(map[string]messenger.Messenger),
|
messengers: make(map[string]messenger.Messenger),
|
||||||
camps: make(map[int]*models.Campaign, 0),
|
camps: make(map[int]*models.Campaign, 0),
|
||||||
|
@ -189,6 +193,11 @@ func (r *Runner) Run(tick time.Duration) {
|
||||||
r.exhaustCampaign(e.camp, models.CampaignStatusPaused)
|
r.exhaustCampaign(e.camp, models.CampaignStatusPaused)
|
||||||
}
|
}
|
||||||
delete(r.msgErrorCounts, e.camp.ID)
|
delete(r.msgErrorCounts, e.camp.ID)
|
||||||
|
|
||||||
|
// Notify admins.
|
||||||
|
r.sendNotif(e.camp,
|
||||||
|
models.CampaignStatusPaused,
|
||||||
|
"Too many errors")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -205,12 +214,15 @@ func (r *Runner) Run(tick time.Duration) {
|
||||||
if has {
|
if has {
|
||||||
// There are more subscribers to fetch.
|
// There are more subscribers to fetch.
|
||||||
r.subFetchQueue <- c
|
r.subFetchQueue <- c
|
||||||
} else {
|
} else if r.isCampaignProcessing(c.ID) {
|
||||||
// There are no more subscribers. Either the campaign status
|
// There are no more subscribers. Either the campaign status
|
||||||
// has changed or all subscribers have been processed.
|
// has changed or all subscribers have been processed.
|
||||||
if err := r.exhaustCampaign(c, ""); err != nil {
|
newC, err := r.exhaustCampaign(c, "")
|
||||||
|
if err != nil {
|
||||||
r.logger.Printf("error exhausting campaign (%s): %v", c.Name, err)
|
r.logger.Printf("error exhausting campaign (%s): %v", c.Name, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
r.sendNotif(newC, newC.Status, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,7 +239,7 @@ func (r *Runner) SpawnWorkers() {
|
||||||
|
|
||||||
err := r.messengers[m.Campaign.MessengerID].Push(
|
err := r.messengers[m.Campaign.MessengerID].Push(
|
||||||
m.from,
|
m.from,
|
||||||
m.to,
|
[]string{m.to},
|
||||||
m.Campaign.Subject,
|
m.Campaign.Subject,
|
||||||
m.Body)
|
m.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -311,7 +323,7 @@ func (r *Runner) isCampaignProcessing(id int) bool {
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) exhaustCampaign(c *models.Campaign, status string) error {
|
func (r *Runner) exhaustCampaign(c *models.Campaign, status string) (*models.Campaign, error) {
|
||||||
delete(r.camps, c.ID)
|
delete(r.camps, c.ID)
|
||||||
|
|
||||||
// A status has been passed. Change the campaign's status
|
// A status has been passed. Change the campaign's status
|
||||||
|
@ -322,18 +334,18 @@ func (r *Runner) exhaustCampaign(c *models.Campaign, status string) error {
|
||||||
} else {
|
} else {
|
||||||
r.logger.Printf("set campaign (%s) to %s", c.Name, status)
|
r.logger.Printf("set campaign (%s) to %s", c.Name, status)
|
||||||
}
|
}
|
||||||
|
return c, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the up-to-date campaign status from the source.
|
// Fetch the up-to-date campaign status from the source.
|
||||||
cm, err := r.src.GetCampaign(c.ID)
|
cm, err := r.src.GetCampaign(c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a running campaign has exhausted subscribers, it's finished.
|
// If a running campaign has exhausted subscribers, it's finished.
|
||||||
if cm.Status == models.CampaignStatusRunning {
|
if cm.Status == models.CampaignStatusRunning {
|
||||||
|
cm.Status = models.CampaignStatusFinished
|
||||||
if err := r.src.UpdateCampaignStatus(c.ID, models.CampaignStatusFinished); err != nil {
|
if err := r.src.UpdateCampaignStatus(c.ID, models.CampaignStatusFinished); err != nil {
|
||||||
r.logger.Printf("error finishing campaign (%s): %v", c.Name, err)
|
r.logger.Printf("error finishing campaign (%s): %v", c.Name, err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -343,7 +355,7 @@ func (r *Runner) exhaustCampaign(c *models.Campaign, status string) error {
|
||||||
r.logger.Printf("stop processing campaign (%s)", c.Name)
|
r.logger.Printf("stop processing campaign (%s)", c.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return cm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render takes a Message, executes its pre-compiled Campaign.Tpl
|
// Render takes a Message, executes its pre-compiled Campaign.Tpl
|
||||||
|
@ -383,6 +395,23 @@ func (r *Runner) trackLink(url, campUUID, subUUID string) string {
|
||||||
return fmt.Sprintf(r.cfg.LinkTrackURL, uu, campUUID, subUUID)
|
return fmt.Sprintf(r.cfg.LinkTrackURL, uu, campUUID, subUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendNotif sends a notification to registered admin e-mails.
|
||||||
|
func (r *Runner) sendNotif(c *models.Campaign, status, reason string) error {
|
||||||
|
var (
|
||||||
|
subject = fmt.Sprintf("%s: %s", strings.Title(status), c.Name)
|
||||||
|
data = map[string]interface{}{
|
||||||
|
"ID": c.ID,
|
||||||
|
"Name": c.Name,
|
||||||
|
"Status": status,
|
||||||
|
"Sent": c.Sent,
|
||||||
|
"ToSend": c.ToSend,
|
||||||
|
"Reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return r.notifCB(subject, data)
|
||||||
|
}
|
||||||
|
|
||||||
// TemplateFuncs returns the template functions to be applied into
|
// TemplateFuncs returns the template functions to be applied into
|
||||||
// compiled campaign templates.
|
// compiled campaign templates.
|
||||||
func (r *Runner) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
func (r *Runner) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
|
@ -53,13 +54,14 @@ const (
|
||||||
|
|
||||||
// Importer represents the bulk CSV subscriber import system.
|
// Importer represents the bulk CSV subscriber import system.
|
||||||
type Importer struct {
|
type Importer struct {
|
||||||
upsert *sql.Stmt
|
upsert *sql.Stmt
|
||||||
blacklist *sql.Stmt
|
blacklist *sql.Stmt
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
notifCB models.AdminNotifCallback
|
||||||
|
|
||||||
isImporting bool
|
isImporting bool
|
||||||
stop chan bool
|
stop chan bool
|
||||||
|
status *Status
|
||||||
status *Status
|
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,12 +101,13 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// New returns a new instance of Importer.
|
// New returns a new instance of Importer.
|
||||||
func New(upsert *sql.Stmt, blacklist *sql.Stmt, db *sql.DB) *Importer {
|
func New(upsert *sql.Stmt, blacklist *sql.Stmt, db *sql.DB, notifCB models.AdminNotifCallback) *Importer {
|
||||||
im := Importer{
|
im := Importer{
|
||||||
upsert: upsert,
|
upsert: upsert,
|
||||||
blacklist: blacklist,
|
blacklist: blacklist,
|
||||||
stop: make(chan bool, 1),
|
stop: make(chan bool, 1),
|
||||||
db: db,
|
db: db,
|
||||||
|
notifCB: notifCB,
|
||||||
status: &Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
|
status: &Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,6 +186,24 @@ func (im *Importer) incrementImportCount(n int) {
|
||||||
im.Unlock()
|
im.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendNotif sends admin notifications for import completions.
|
||||||
|
func (im *Importer) sendNotif(status string) error {
|
||||||
|
var (
|
||||||
|
s = im.GetStats()
|
||||||
|
data = map[string]interface{}{
|
||||||
|
"Name": s.Name,
|
||||||
|
"Status": status,
|
||||||
|
"Imported": s.Imported,
|
||||||
|
"Total": s.Total,
|
||||||
|
}
|
||||||
|
subject = fmt.Sprintf("%s: %s import",
|
||||||
|
strings.Title(status),
|
||||||
|
s.Name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return im.notifCB(subject, data)
|
||||||
|
}
|
||||||
|
|
||||||
// Start is a blocking function that selects on a channel queue until all
|
// Start is a blocking function that selects on a channel queue until all
|
||||||
// subscriber entries in the import session are imported. It should be
|
// subscriber entries in the import session are imported. It should be
|
||||||
// invoked as a goroutine.
|
// invoked as a goroutine.
|
||||||
|
@ -249,6 +270,8 @@ func (s *Session) Start() {
|
||||||
if cur == 0 {
|
if cur == 0 {
|
||||||
s.im.setStatus(StatusFinished)
|
s.im.setStatus(StatusFinished)
|
||||||
s.log.Printf("imported finished")
|
s.log.Printf("imported finished")
|
||||||
|
s.im.sendNotif(StatusFinished)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,12 +280,14 @@ func (s *Session) Start() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
s.im.setStatus(StatusFailed)
|
s.im.setStatus(StatusFailed)
|
||||||
s.log.Printf("error committing to DB: %v", err)
|
s.log.Printf("error committing to DB: %v", err)
|
||||||
|
s.im.sendNotif(StatusFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.im.incrementImportCount(cur)
|
s.im.incrementImportCount(cur)
|
||||||
s.im.setStatus(StatusFinished)
|
s.im.setStatus(StatusFinished)
|
||||||
s.log.Printf("imported finished")
|
s.log.Printf("imported finished")
|
||||||
|
s.im.sendNotif(StatusFinished)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractZIP takes a ZIP file's path and extracts all .csv files in it to
|
// ExtractZIP takes a ZIP file's path and extracts all .csv files in it to
|
||||||
|
|
83
templates/base.html
Normal file
83
templates/base.html
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
{{ define "header" }}
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||||
|
<base target="_blank">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #F0F1F3;
|
||||||
|
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 26px;
|
||||||
|
margin: 0;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 30px;
|
||||||
|
max-width: 525px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #7f2aff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.wrap {
|
||||||
|
max-width: auto;
|
||||||
|
}
|
||||||
|
.gutter {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: #F0F1F3;">
|
||||||
|
<div class="gutter"> </div>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="header">
|
||||||
|
<a href="{{ index . "RootURL" }}"><img src="{{ index . "RootURL" }}/public/static/logo.svg" alt="listmonk" /></a>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "footer" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Powered by <a href="https://listmonk.app" target="_blank">listmonk</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="gutter"> </div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
25
templates/campaign-status.html
Normal file
25
templates/campaign-status.html
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
{{ define "campaign-status" }}
|
||||||
|
{{ template "header" . }}
|
||||||
|
<h2>Campaign update</h2>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Campaign</strong></td>
|
||||||
|
<td><a href="{{ index . "RootURL" }}/campaigns/{{ index . "ID" }}">{{ index . "Name" }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Status</strong></td>
|
||||||
|
<td>{{ index . "Status" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Sent</strong></td>
|
||||||
|
<td>{{ index . "Sent" }} / {{ index . "ToSend" }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ if ne (index . "Reason") "" }}
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Reason</strong></td>
|
||||||
|
<td>{{ index . "Reason" }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</table>
|
||||||
|
{{ template "footer" }}
|
||||||
|
{{ end }}
|
19
templates/import-status.html
Normal file
19
templates/import-status.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{{ define "import-status" }}
|
||||||
|
{{ template "header" . }}
|
||||||
|
<h2>Import update</h2>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>File</strong></td>
|
||||||
|
<td><a href="{{ .RootURL }}/subscribers/import">{{ .Name }}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Status</strong></td>
|
||||||
|
<td>{{ .Status }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="30%"><strong>Records</strong></td>
|
||||||
|
<td>{{ .Imported }} / {{ .Total }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{ template "footer" }}
|
||||||
|
{{ end }}
|
Loading…
Reference in a new issue