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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,11 @@ root = "http://listmonk.mysite.com"
|
|||
# The default 'from' e-mail for outgoing e-mail campaigns.
|
||||
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.
|
||||
upload_path = "uploads"
|
||||
|
||||
|
|
|
@ -110,8 +110,8 @@ func handleGetImportSubscribers(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{s})
|
||||
}
|
||||
|
||||
// handleGetImportSubscriberLogs returns import statistics.
|
||||
func handleGetImportSubscriberLogs(c echo.Context) error {
|
||||
// handleGetImportSubscriberStats returns import statistics.
|
||||
func handleGetImportSubscriberStats(c echo.Context) error {
|
||||
app := c.Get("app").(*App)
|
||||
return c.JSON(http.StatusOK, okResp{string(app.Importer.GetLogs())})
|
||||
}
|
||||
|
|
|
@ -129,7 +129,7 @@ func install(app *App, qMap goyesql.Queries) {
|
|||
}
|
||||
|
||||
// Default template.
|
||||
tplBody, err := ioutil.ReadFile("default-template.html")
|
||||
tplBody, err := ioutil.ReadFile("templates/default.tpl")
|
||||
if err != nil {
|
||||
tplBody = []byte(tplTag)
|
||||
}
|
||||
|
|
45
main.go
45
main.go
|
@ -20,14 +20,13 @@ import (
|
|||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var logger *log.Logger
|
||||
|
||||
type constants struct {
|
||||
AssetPath string `mapstructure:"asset_path"`
|
||||
RootURL string `mapstructure:"root"`
|
||||
UploadPath string `mapstructure:"upload_path"`
|
||||
UploadURI string `mapstructure:"upload_uri"`
|
||||
FromEmail string `mapstructure:"from_email"`
|
||||
AssetPath string `mapstructure:"asset_path"`
|
||||
RootURL string `mapstructure:"root"`
|
||||
UploadPath string `mapstructure:"upload_path"`
|
||||
UploadURI string `mapstructure:"upload_uri"`
|
||||
FromEmail string `mapstructure:"from_email"`
|
||||
NotifyEmails []string `mapstructure:"notify_emails"`
|
||||
}
|
||||
|
||||
// App contains the "global" components that are
|
||||
|
@ -39,10 +38,12 @@ type App struct {
|
|||
Importer *subimporter.Importer
|
||||
Runner *runner.Runner
|
||||
Logger *log.Logger
|
||||
|
||||
NotifTpls *template.Template
|
||||
Messenger messenger.Messenger
|
||||
}
|
||||
|
||||
var logger *log.Logger
|
||||
|
||||
func init() {
|
||||
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.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.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
||||
|
||||
|
@ -158,7 +159,6 @@ func initMessengers(r *runner.Runner) messenger.Messenger {
|
|||
|
||||
var s messenger.Server
|
||||
viper.UnmarshalKey("smtp."+name, &s)
|
||||
|
||||
s.Name = name
|
||||
s.SendTimeout = s.SendTimeout * time.Millisecond
|
||||
srv = append(srv, s)
|
||||
|
@ -170,7 +170,6 @@ func initMessengers(r *runner.Runner) messenger.Messenger {
|
|||
if err != nil {
|
||||
logger.Fatalf("error loading e-mail messenger: %v", err)
|
||||
}
|
||||
|
||||
if err := r.AddMessenger(msgr); err != nil {
|
||||
logger.Printf("error registering messenger %s", err)
|
||||
}
|
||||
|
@ -220,14 +219,32 @@ func main() {
|
|||
if err := scanQueriesToStruct(q, qMap, db.Unsafe()); err != nil {
|
||||
logger.Fatalf("no SQL queries loaded: %v", err)
|
||||
}
|
||||
|
||||
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.
|
||||
campNotifCB := func(subject string, data map[string]interface{}) error {
|
||||
return sendNotification(notifTplCampaign, subject, data, app)
|
||||
}
|
||||
r := runner.New(runner.Config{
|
||||
Concurrency: viper.GetInt("app.concurrency"),
|
||||
MaxSendErrors: viper.GetInt("app.max_send_errors"),
|
||||
FromEmail: app.Constants.FromEmail,
|
||||
|
||||
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
||||
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
|
||||
ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL),
|
||||
}, newRunnerDB(q), logger)
|
||||
}, newRunnerDB(q), campNotifCB, logger)
|
||||
app.Runner = r
|
||||
|
||||
// Add messengers.
|
||||
|
|
|
@ -66,7 +66,7 @@ func (e *emailer) Name() string {
|
|||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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]
|
||||
err := srv.mailer.Send(&email.Email{
|
||||
From: fromAddr,
|
||||
To: []string{toAddr},
|
||||
To: toAddr,
|
||||
Subject: subject,
|
||||
HTML: m,
|
||||
}, srv.SendTimeout)
|
||||
|
|
|
@ -5,6 +5,6 @@ package messenger
|
|||
type Messenger interface {
|
||||
Name() string
|
||||
|
||||
Push(fromAddr, toAddr, subject string, message []byte) error
|
||||
Push(fromAddr string, toAddr []string, subject string, message []byte) error
|
||||
Flush() error
|
||||
}
|
||||
|
|
|
@ -58,6 +58,10 @@ var (
|
|||
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.
|
||||
type Base struct {
|
||||
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"
|
||||
"html/template"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
@ -38,6 +39,7 @@ type Runner struct {
|
|||
cfg Config
|
||||
src DataSource
|
||||
messengers map[string]messenger.Messenger
|
||||
notifCB models.AdminNotifCallback
|
||||
logger *log.Logger
|
||||
|
||||
// Campaigns that are currently running.
|
||||
|
@ -70,6 +72,7 @@ type Config struct {
|
|||
Concurrency int
|
||||
MaxSendErrors int
|
||||
RequeueOnError bool
|
||||
FromEmail string
|
||||
LinkTrackURL string
|
||||
UnsubscribeURL string
|
||||
ViewTrackURL string
|
||||
|
@ -81,10 +84,11 @@ type msgError struct {
|
|||
}
|
||||
|
||||
// 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{
|
||||
cfg: cfg,
|
||||
src: src,
|
||||
notifCB: notifCB,
|
||||
logger: l,
|
||||
messengers: make(map[string]messenger.Messenger),
|
||||
camps: make(map[int]*models.Campaign, 0),
|
||||
|
@ -189,6 +193,11 @@ func (r *Runner) Run(tick time.Duration) {
|
|||
r.exhaustCampaign(e.camp, models.CampaignStatusPaused)
|
||||
}
|
||||
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 {
|
||||
// There are more subscribers to fetch.
|
||||
r.subFetchQueue <- c
|
||||
} else {
|
||||
} else if r.isCampaignProcessing(c.ID) {
|
||||
// There are no more subscribers. Either the campaign status
|
||||
// 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)
|
||||
continue
|
||||
}
|
||||
r.sendNotif(newC, newC.Status, "")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -227,7 +239,7 @@ func (r *Runner) SpawnWorkers() {
|
|||
|
||||
err := r.messengers[m.Campaign.MessengerID].Push(
|
||||
m.from,
|
||||
m.to,
|
||||
[]string{m.to},
|
||||
m.Campaign.Subject,
|
||||
m.Body)
|
||||
if err != nil {
|
||||
|
@ -311,7 +323,7 @@ func (r *Runner) isCampaignProcessing(id int) bool {
|
|||
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)
|
||||
|
||||
// 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 {
|
||||
r.logger.Printf("set campaign (%s) to %s", c.Name, status)
|
||||
}
|
||||
|
||||
return nil
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Fetch the up-to-date campaign status from the source.
|
||||
cm, err := r.src.GetCampaign(c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a running campaign has exhausted subscribers, it's finished.
|
||||
if cm.Status == models.CampaignStatusRunning {
|
||||
cm.Status = models.CampaignStatusFinished
|
||||
if err := r.src.UpdateCampaignStatus(c.ID, models.CampaignStatusFinished); err != nil {
|
||||
r.logger.Printf("error finishing campaign (%s): %v", c.Name, err)
|
||||
} else {
|
||||
|
@ -343,7 +355,7 @@ func (r *Runner) exhaustCampaign(c *models.Campaign, status string) error {
|
|||
r.logger.Printf("stop processing campaign (%s)", c.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// compiled campaign templates.
|
||||
func (r *Runner) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
@ -53,13 +54,14 @@ const (
|
|||
|
||||
// Importer represents the bulk CSV subscriber import system.
|
||||
type Importer struct {
|
||||
upsert *sql.Stmt
|
||||
blacklist *sql.Stmt
|
||||
db *sql.DB
|
||||
upsert *sql.Stmt
|
||||
blacklist *sql.Stmt
|
||||
db *sql.DB
|
||||
notifCB models.AdminNotifCallback
|
||||
|
||||
isImporting bool
|
||||
stop chan bool
|
||||
|
||||
status *Status
|
||||
status *Status
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
|
@ -99,12 +101,13 @@ var (
|
|||
)
|
||||
|
||||
// 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{
|
||||
upsert: upsert,
|
||||
blacklist: blacklist,
|
||||
stop: make(chan bool, 1),
|
||||
db: db,
|
||||
notifCB: notifCB,
|
||||
status: &Status{Status: StatusNone, logBuf: bytes.NewBuffer(nil)},
|
||||
}
|
||||
|
||||
|
@ -183,6 +186,24 @@ func (im *Importer) incrementImportCount(n int) {
|
|||
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
|
||||
// subscriber entries in the import session are imported. It should be
|
||||
// invoked as a goroutine.
|
||||
|
@ -249,6 +270,8 @@ func (s *Session) Start() {
|
|||
if cur == 0 {
|
||||
s.im.setStatus(StatusFinished)
|
||||
s.log.Printf("imported finished")
|
||||
s.im.sendNotif(StatusFinished)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -257,12 +280,14 @@ func (s *Session) Start() {
|
|||
tx.Rollback()
|
||||
s.im.setStatus(StatusFailed)
|
||||
s.log.Printf("error committing to DB: %v", err)
|
||||
s.im.sendNotif(StatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
s.im.incrementImportCount(cur)
|
||||
s.im.setStatus(StatusFinished)
|
||||
s.log.Printf("imported finished")
|
||||
s.im.sendNotif(StatusFinished)
|
||||
}
|
||||
|
||||
// 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