Turn notifs into a special stateful global singleton package, removing clunky deps.

This commit is contained in:
Kailash Nadh 2025-04-05 22:45:19 +05:30
parent e327ebbbdf
commit e2f24a140e
11 changed files with 80 additions and 90 deletions

View file

@ -14,6 +14,7 @@ import (
"time"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
@ -639,7 +640,7 @@ func (a *App) makeOptinCampaignMessage(o campReq) (campReq, error) {
// Prepare sample opt-in message for the campaign.
var b bytes.Buffer
if err := a.notifs.Tpls.ExecuteTemplate(&b, "optin-campaign", struct {
if err := notifs.Tpls.ExecuteTemplate(&b, "optin-campaign", struct {
Lists []models.List
OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil {

View file

@ -484,11 +484,7 @@ func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
}
// initCampaignManager initializes the campaign manager.
func initCampaignManager(q *models.Queries, cs *constants, fnNotif notifs.FuncNotifSystem, co *core.Core, md media.Store, i *i18n.I18n) *manager.Manager {
campNotifCB := func(subject string, data any) error {
return fnNotif(subject, notifTplCampaign, data, nil)
}
func initCampaignManager(q *models.Queries, cs *constants, co *core.Core, md media.Store, i *i18n.I18n) *manager.Manager {
if ko.Bool("passive") {
lo.Println("running in passive mode. won't process campaigns.")
}
@ -513,7 +509,7 @@ func initCampaignManager(q *models.Queries, cs *constants, fnNotif notifs.FuncNo
SlidingWindowRate: ko.Int("app.message_sliding_window_rate"),
ScanInterval: time.Second * 5,
ScanCampaigns: !ko.Bool("passive"),
}, newManagerStore(q, co, md), campNotifCB, i, lo)
}, newManagerStore(q, co, md), i, lo)
}
// initTxTemplates initializes and compiles the transactional templates and caches them in-memory.
@ -545,12 +541,12 @@ func initImporter(q *models.Queries, db *sqlx.DB, core *core.Core, app *App) *su
// Hook for triggering admin notifications and refreshing stats materialized
// views after a successful import.
NotifCB: func(subject string, data any) error {
PostCB: func(subject string, data any) error {
// Refresh cached subscriber counts and stats.
core.RefreshMatViews(true)
// Send admin notification.
app.notifs.NotifySystem(subject, notifTplImport, data, nil)
notifs.NotifySystem(subject, notifs.TplImport, data, nil)
return nil
},
}, db.DB, app.i18n)
@ -677,7 +673,7 @@ func initMediaStore(ko *koanf.Koanf) media.Store {
}
// initNotifs initializes the notifier with the system e-mail templates.
func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, pushFn notifs.FuncPush, cs *constants, ko *koanf.Koanf) *notifs.Notifs {
func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, em *email.Emailer, cs *constants, ko *koanf.Koanf) {
tpls, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, cs), fs, "/static/email-templates/*.html")
if err != nil {
lo.Fatalf("error parsing e-mail notif templates: %v", err)
@ -701,11 +697,11 @@ func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, pushFn notifs.FuncPush, cs
lo.Println("system e-mail templates are plaintext")
}
return notifs.NewNotifs(notifs.Opt{
notifs.Initialize(notifs.Opt{
FromEmail: ko.MustString("app.from_email"),
SystemEmails: ko.MustStrings("app.notify_emails"),
ContentType: contentType,
}, tpls, pushFn, lo)
}, tpls, em, lo)
}
// initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks

View file

@ -24,7 +24,7 @@ import (
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
notifs "github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/internal/messenger/email"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/knadh/paginator"
@ -53,7 +53,6 @@ type App struct {
paginator *paginator.Paginator
captcha *captcha.Captcha
events *events.Events
notifs *notifs.Notifs
about about
log *log.Logger
bufLog *buflog.BufLog
@ -216,7 +215,7 @@ func main() {
})
app.queries = queries
app.manager = initCampaignManager(app.queries, app.constants, app.notifs.NotifySystem, app.core, app.media, app.i18n)
app.manager = initCampaignManager(app.queries, app.constants, app.core, app.media, app.i18n)
app.importer = initImporter(app.queries, db, app.core, app)
hasUsers, auth := initAuth(app.core, db.DB, ko)
@ -226,10 +225,6 @@ func main() {
// for new user setup.
app.needsUserSetup = !hasUsers
// Initialize admin email notification templates.
app.notifs = initNotifs(app.fs, app.i18n, app.manager.PushMessage, app.constants, ko)
initTxTemplates(app.manager, app.core)
// Initialize the bounce manager that processes bounces from webhooks and
// POP3 mailbox scanning.
if ko.Bool("bounce.enabled") {
@ -245,6 +240,10 @@ func main() {
}
}
// Initialize admin email notification templates.
initNotifs(app.fs, app.i18n, app.emailMessenger.(*email.Emailer), app.constants, ko)
initTxTemplates(app.manager, app.core)
// Initialize any additional postback messengers.
app.messengers = append(app.messengers, initPostbackMessengers(ko)...)

View file

@ -1,28 +0,0 @@
package main
import (
"regexp"
"strings"
)
const (
notifTplImport = "import-status"
notifTplCampaign = "campaign-status"
notifSubscriberOptin = "subscriber-optin"
notifSubscriberData = "subscriber-data"
)
var (
reTitle = regexp.MustCompile(`(?s)<title\s*data-i18n\s*>(.+?)</title>`)
)
// getTplSubject extrcts any custom i18n subject rendered in the given rendered
// template body. If it's not found, the incoming subject and body are returned.
func getTplSubject(subject string, body []byte) (string, []byte) {
m := reTitle.FindSubmatch(body)
if len(m) != 2 {
return subject, body
}
return strings.TrimSpace(string(m[1])), reTitle.ReplaceAll(body, []byte(""))
}

View file

@ -14,6 +14,7 @@ import (
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
@ -563,13 +564,14 @@ func (a *App) SelfExportSubscriberData(c echo.Context) error {
// Prepare the attachment e-mail.
var msg bytes.Buffer
if err := a.notifs.Tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
a.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
if err := notifs.Tpls.ExecuteTemplate(&msg, notifs.TplSubscriberData, data); err != nil {
a.log.Printf("error compiling notification template '%s': %v", notifs.TplSubscriberData, err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorProcessingRequest")))
}
subject, body := getTplSubject(a.i18n.Ts("email.data.title"), msg.Bytes())
// TODO: GetTplSubject should be moved to a utils package.
subject, body := notifs.GetTplSubject(a.i18n.Ts("email.data.title"), msg.Bytes())
// E-mail the data as a JSON attachment to the subscriber.
const fname = "data.json"

View file

@ -18,6 +18,7 @@ import (
"github.com/knadh/koanf/providers/rawbytes"
"github.com/knadh/koanf/v2"
"github.com/knadh/listmonk/internal/messenger/email"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
@ -323,7 +324,7 @@ func (a *App) TestSMTPSettings(c echo.Context) error {
// Render the test email template body.
var b bytes.Buffer
if err := a.notifs.Tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil {
if err := notifs.Tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil {
a.log.Printf("error compiling notification template '%s': %v", "smtp-test", err)
return err
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
@ -595,7 +596,7 @@ func (app *App) optinConfirmNotify() func(sub models.Subscriber, listIDs []int)
}
// Send the e-mail.
if err := app.notifs.Notify([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out, hdr); err != nil {
if err := notifs.Notify([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifs.TplSubscriberOptin, out, hdr); err != nil {
app.log.Printf("error sending opt-in e-mail for subscriber %d (%s): %s", sub.ID, sub.UUID, err)
return 0, err
}

View file

@ -14,6 +14,7 @@ import (
"github.com/Masterminds/sprig/v3"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/models"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@ -64,7 +65,7 @@ type Manager struct {
store Store
i18n *i18n.I18n
messengers map[string]Messenger
notifCB models.NotifCallback
fnNotify func(subject string, data any) error
log *log.Logger
// Campaigns that are currently running.
@ -145,7 +146,7 @@ type Config struct {
var pushTimeout = time.Second * 3
// New returns a new instance of Mailer.
func New(cfg Config, store Store, notifCB models.NotifCallback, i *i18n.I18n, l *log.Logger) *Manager {
func New(cfg Config, store Store, i *i18n.I18n, l *log.Logger) *Manager {
if cfg.BatchSize < 1 {
cfg.BatchSize = 1000
}
@ -157,10 +158,12 @@ func New(cfg Config, store Store, notifCB models.NotifCallback, i *i18n.I18n, l
}
m := &Manager{
cfg: cfg,
store: store,
i18n: i,
notifCB: notifCB,
cfg: cfg,
store: store,
i18n: i,
fnNotify: func(subject string, data any) error {
return notifs.NotifySystem(subject, notifs.TplCampaignStatus, data, nil)
},
log: l,
messengers: make(map[string]Messenger),
pipes: make(map[int]*pipe),
@ -588,7 +591,7 @@ func (m *Manager) sendNotif(c *models.Campaign, status, reason string) error {
}
)
return m.notifCB(subject, data)
return m.fnNotify(subject, data)
}
// makeGnericFuncMap returns a generic template func map with custom template

View file

@ -1,3 +1,7 @@
// package notifs is a special singleton, stateful globally accessible package
// that handles sending out arbitrary notifications to the admin and users.
// It's initialized once in the main package and is accessed globally across
// other packages.
package notifs
import (
@ -8,9 +12,17 @@ import (
"regexp"
"strings"
"github.com/knadh/listmonk/internal/messenger/email"
"github.com/knadh/listmonk/models"
)
const (
TplImport = "import-status"
TplCampaignStatus = "campaign-status"
TplSubscriberOptin = "subscriber-optin"
TplSubscriberData = "subscriber-data"
)
type FuncPush func(msg models.Message) error
type FuncNotif func(toEmails []string, subject, tplName string, data any, headers textproto.MIMEHeader) error
type FuncNotifSystem func(subject, tplName string, data any, headers textproto.MIMEHeader) error
@ -21,52 +33,58 @@ type Opt struct {
ContentType string
}
var (
reTitle = regexp.MustCompile(`(?s)<title\s*data-i18n\s*>(.+?)</title>`)
)
type Notifs struct {
Tpls *template.Template
pushFn FuncPush
lo *log.Logger
em *email.Emailer
lo *log.Logger
opt Opt
}
// NewNotifs returns a new Notifs instance.
func NewNotifs(opt Opt, tpls *template.Template, pushFn FuncPush, lo *log.Logger) *Notifs {
return &Notifs{
opt: opt,
Tpls: tpls,
pushFn: pushFn,
lo: lo,
var (
reTitle = regexp.MustCompile(`(?s)<title\s*data-i18n\s*>(.+?)</title>`)
Tpls *template.Template
no *Notifs
)
// Initialize returns a new Notifs instance.
func Initialize(opt Opt, tpls *template.Template, em *email.Emailer, lo *log.Logger) {
if no != nil {
lo.Fatal("notifs already initialized")
}
Tpls = tpls
no = &Notifs{
opt: opt,
em: em,
lo: lo,
}
}
// NotifySystem sends out an e-mail notification to the admin emails.
func (n *Notifs) NotifySystem(subject, tplName string, data any, hdr textproto.MIMEHeader) error {
return n.Notify(n.opt.SystemEmails, subject, tplName, data, hdr)
func NotifySystem(subject, tplName string, data any, hdr textproto.MIMEHeader) error {
return Notify(no.opt.SystemEmails, subject, tplName, data, hdr)
}
// Notify sends out an e-mail notification.
func (n *Notifs) Notify(toEmails []string, subject, tplName string, data any, hdr textproto.MIMEHeader) error {
func Notify(toEmails []string, subject, tplName string, data any, hdr textproto.MIMEHeader) error {
if len(toEmails) == 0 {
return nil
}
var buf bytes.Buffer
if err := n.Tpls.ExecuteTemplate(&buf, tplName, data); err != nil {
n.lo.Printf("error compiling notification template '%s': %v", tplName, err)
if err := Tpls.ExecuteTemplate(&buf, tplName, data); err != nil {
no.lo.Printf("error compiling notification template '%s': %v", tplName, err)
return err
}
body := buf.Bytes()
subject, body = getTplSubject(subject, body)
subject, body = GetTplSubject(subject, body)
m := models.Message{
Messenger: "email",
ContentType: n.opt.ContentType,
From: n.opt.FromEmail,
ContentType: no.opt.ContentType,
From: no.opt.FromEmail,
To: toEmails,
Subject: subject,
Body: body,
@ -74,17 +92,17 @@ func (n *Notifs) Notify(toEmails []string, subject, tplName string, data any, hd
}
// Send the message.
if err := n.pushFn(m); err != nil {
n.lo.Printf("error sending admin notification (%s): %v", subject, err)
if err := no.em.Push(m); err != nil {
no.lo.Printf("error sending admin notification (%s): %v", subject, err)
return err
}
return nil
}
// getTplSubject extracts any custom i18n subject rendered in the given rendered
// GetTplSubject extracts any custom i18n subject rendered in the given rendered
// template body. If it's not found, the incoming subject and body are returned.
func getTplSubject(subject string, body []byte) (string, []byte) {
func GetTplSubject(subject string, body []byte) (string, []byte) {
m := reTitle.FindSubmatch(body)
if len(m) != 2 {
return subject, body

View file

@ -71,7 +71,7 @@ type Options struct {
UpsertStmt *sql.Stmt
BlocklistStmt *sql.Stmt
UpdateListDateStmt *sql.Stmt
NotifCB models.NotifCallback
PostCB func(subject string, data any) error
DomainBlocklist []string
DomainAllowlist []string
@ -255,7 +255,7 @@ func (im *Importer) sendNotif(status string) error {
}
subject = fmt.Sprintf("%s: %s import", cases.Title(language.Und).String(status), s.Name)
)
return im.opt.NotifCB(subject, out)
return im.opt.PostCB(subject, out)
}
// Start is a blocking function that selects on a channel queue until all

View file

@ -119,9 +119,6 @@ var regTplFuncs = []regTplFunc{
},
}
// NotifCallback is a callback function that triggers a notification.
type NotifCallback func(subject string, data any) error
// PageResults is a generic HTTP response container for paginated results of list of items.
type PageResults struct {
Results any `json:"results"`