소스 검색

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
Kailash Nadh 6 년 전
부모
커밋
c24c19b120
16개의 변경된 파일386개의 추가작업 그리고 36개의 파일을 삭제
  1. 1 1
      campaigns.go
  2. 5 0
      config.toml.sample
  3. 2 2
      import.go
  4. 1 1
      install.go
  5. 31 14
      main.go
  6. 2 2
      messenger/emailer.go
  7. 1 1
      messenger/messenger.go
  8. 4 0
      models/models.go
  9. 32 0
      notifications.go
  10. 111 0
      public/static/logo.svg
  11. 38 9
      runner/runner.go
  12. 31 6
      subimporter/importer.go
  13. 83 0
      templates/base.html
  14. 25 0
      templates/campaign-status.html
  15. 0 0
      templates/default.tpl
  16. 19 0
      templates/import-status.html

+ 1 - 1
campaigns.go

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

+ 5 - 0
config.toml.sample

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

+ 2 - 2
import.go

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

+ 1 - 1
install.go

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

+ 31 - 14
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.

+ 2 - 2
messenger/emailer.go

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

+ 1 - 1
messenger/messenger.go

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

+ 4 - 0
models/models.go

@@ -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 - 0
notifications.go

@@ -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 - 0
public/static/logo.svg

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

+ 38 - 9
runner/runner.go

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

+ 31 - 6
subimporter/importer.go

@@ -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 - 0
templates/base.html

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

+ 25 - 0
templates/campaign-status.html

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

+ 0 - 0
default-template.html → templates/default.tpl


+ 19 - 0
templates/import-status.html

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