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:
Kailash Nadh 2018-11-28 13:29:57 +05:30
parent 8a0a7a195e
commit c24c19b120
16 changed files with 386 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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">&nbsp;</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">&nbsp;</div>
</body>
</html>
{{ end }}

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

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