Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e0b82a7
feat: add webhook for different event types to settings
meysam81 Dec 28, 2025
71aa6a7
fix: add label proportional to plural form
meysam81 Dec 28, 2025
2f5460a
fix: update missing translation
meysam81 Dec 28, 2025
f44fd74
fix: move webhook model to its own struct for reusability
meysam81 Dec 28, 2025
e3e43c2
fix: use set-like events for fast lookup
meysam81 Dec 28, 2025
826ba27
fix: use listmonk version for user agent
meysam81 Dec 28, 2025
5939645
fix: add url validation to webhook update
meysam81 Dec 29, 2025
fc1fd14
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 1, 2026
fc0b120
add webhook_logs table with background worker pool
meysam81 Jan 1, 2026
2219faa
feat: move persistent worker to single goroutine with db-backed retries
meysam81 Jan 1, 2026
98744db
fix: update the webhook channel max size
meysam81 Jan 1, 2026
93f915e
feat: remove HMAC and add token to webhook
meysam81 Jan 1, 2026
752007e
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 4, 2026
4964154
chore: upgrade migration after v6 release
meysam81 Jan 4, 2026
83c757c
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 5, 2026
2191c5d
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 12, 2026
34c0888
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 15, 2026
adda3b8
Merge remote-tracking branch 'upstream/HEAD' into meysam/add-webhook
meysam81 Jan 15, 2026
f7dd1a9
Merge remote-tracking branch 'upstream/HEAD' into meysam/add-webhook
meysam81 Jan 20, 2026
b6eafbc
Merge remote-tracking branch 'upstream/master' into meysam/add-webhook
meysam81 Jan 29, 2026
5682930
Merge remote-tracking branch 'upstream/HEAD' into meysam/add-webhook
meysam81 Feb 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.PUT("/api/roles/lists/:id", pm(hasID(a.UpdateListRole), "roles:manage"))
g.DELETE("/api/roles/:id", pm(hasID(a.DeleteRole), "roles:manage"))

// Webhook events list (webhooks are configured via settings).
g.GET("/api/settings/webhooks/events", pm(a.GetWebhookEvents, "settings:get"))

if a.cfg.BounceWebhooksEnabled {
// Private authenticated bounce endpoint.
g.POST("/webhooks/bounce", pm(a.BounceWebhook, "webhooks:post_bounce"))
Expand Down
6 changes: 5 additions & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import (
"github.com/knadh/listmonk/internal/messenger/postback"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/internal/webhooks"
"github.com/knadh/listmonk/models"
"github.com/knadh/stuffbin"
"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -531,7 +532,7 @@ func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
}

// initCore initializes the CRUD DB core .
func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error), queries *models.Queries, db *sqlx.DB, i *i18n.I18n, ko *koanf.Koanf) *core.Core {
func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error), queries *models.Queries, db *sqlx.DB, i *i18n.I18n, ko *koanf.Koanf, whMgr *webhooks.Manager) *core.Core {
opt := &core.Opt{
Constants: core.Constants{
SendOptinConfirmation: ko.Bool("app.send_optin_confirmation"),
Expand All @@ -551,6 +552,7 @@ func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error),
// Initialize the CRUD core.
return core.New(opt, &core.Hooks{
SendOptinConfirmation: fnNotify,
TriggerWebhook: whMgr.Trigger,
})
}

Expand Down Expand Up @@ -617,6 +619,8 @@ func initImporter(q *models.Queries, db *sqlx.DB, core *core.Core, i *i18n.I18n,
BlocklistStmt: q.UpsertBlocklistSubscriber.Stmt,
UpdateListDateStmt: q.UpdateListsDate.Stmt,

TriggerWebhook: core.TriggerWebhook,

// Hook for triggering admin notifications and refreshing stats materialized
// views after a successful import.
PostCB: func(subject string, data any) error {
Expand Down
51 changes: 50 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/messenger/email"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/internal/webhooks"
"github.com/knadh/listmonk/models"
"github.com/knadh/paginator"
"github.com/knadh/stuffbin"
Expand All @@ -46,6 +47,7 @@ type App struct {
auth *auth.Auth
media media.Store
bounce *bounce.Manager
webhooks *webhooks.Manager
captcha *captcha.Captcha
i18n *i18n.I18n
pg *paginator.Paginator
Expand Down Expand Up @@ -82,6 +84,8 @@ var (
db *sqlx.DB
queries *models.Queries

webhookMgr *webhooks.Manager

// Compile-time variables.
buildString string
versionString string
Expand Down Expand Up @@ -188,6 +192,9 @@ func init() {

// Prepare queries.
queries = prepareQueries(qMap, db, ko)

// Initialize the webhook manager for outgoing event webhooks.
webhookMgr = webhooks.New(lo, versionString, queries)
}

func main() {
Expand All @@ -207,7 +214,7 @@ func main() {
fbOptinNotify = makeOptinNotifyHook(ko.Bool("privacy.unsubscribe_header"), urlCfg, queries, i18n)

// Crud core.
core = initCore(fbOptinNotify, queries, db, i18n, ko)
core = initCore(fbOptinNotify, queries, db, i18n, ko, webhookMgr)

// Initialize all messengers, SMTP and postback.
msgrs = append(initSMTPMessengers(), initPostbackMessengers(ko)...)
Expand Down Expand Up @@ -254,6 +261,38 @@ func main() {
go bounce.Run()
}

// Load webhooks from settings.
var settings models.Settings
var settingsLoaded bool
if s, err := core.GetSettings(); err == nil {
settings = s
settingsLoaded = true
webhookMgr.Load(settings.Webhooks)
}

// Initialize and start the webhook worker pool.
webhookWorkerCfg := webhooks.WorkerConfig{
NumWorkers: ko.Int("app.webhook_workers"),
BatchSize: ko.Int("app.webhook_batch_size"),
}
if webhookWorkerCfg.NumWorkers < 1 {
webhookWorkerCfg.NumWorkers = 2
}
if webhookWorkerCfg.BatchSize < 1 {
webhookWorkerCfg.BatchSize = 50
}
webhookWorkerPool := webhooks.NewWorkerPool(webhookWorkerCfg, db, queries, lo, versionString)
if settingsLoaded {
webhookWorkerPool.LoadWebhooks(settings.Webhooks)
}
go webhookWorkerPool.Run()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// write each webhook to db for the worker pool to pick up
go core.PersistWebhookLogs(ctx)

// Start cronjobs.
initCron(core, db)

Expand All @@ -277,6 +316,7 @@ func main() {
auth: auth,
media: media,
bounce: bounce,
webhooks: webhookMgr,
captcha: initCaptcha(),
i18n: i18n,
log: lo,
Expand Down Expand Up @@ -324,6 +364,15 @@ func main() {
// Close the campaign manager.
mgr.Close()

// Close the webhook worker pool.
webhookWorkerPool.Close()

// Close the webhook manager.
webhookMgr.Close()

// close persist webhook log goroutine
cancel()

// Close the DB pool.
db.Close()

Expand Down
3 changes: 2 additions & 1 deletion cmd/manager_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ func (s *store) GetCampaign(campID int) (*models.Campaign, error) {
}

// UpdateCampaignStatus updates a campaign's status.
// Uses Core to ensure webhooks are triggered for status changes.
func (s *store) UpdateCampaignStatus(campID int, status string) error {
_, err := s.queries.UpdateCampaignStatus.Exec(campID, status)
_, err := s.core.UpdateCampaignStatus(campID, status)
return err
}

Expand Down
43 changes: 43 additions & 0 deletions cmd/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -71,6 +72,10 @@ func (a *App) GetSettings(c echo.Context) error {
for i := range s.Messengers {
s.Messengers[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Messengers[i].Password))
}
for i := range s.Webhooks {
s.Webhooks[i].AuthBasicPass = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Webhooks[i].AuthBasicPass))
s.Webhooks[i].AuthToken = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Webhooks[i].AuthToken))
}

s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey))
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
Expand Down Expand Up @@ -209,6 +214,44 @@ func (a *App) UpdateSettings(c echo.Context) error {
names[name] = true
}

// Webhooks password/secret handling.
for i, w := range set.Webhooks {
u, err := url.Parse(w.URL)
if err != nil {
return err
}

if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid scheme in the url provided for webhook: %s", w.URL)
}

if u.Host == "" {
return fmt.Errorf("invalid host in the url provided for webhook: %s", w.URL)
}

// UUID to keep track of password changes similar to the SMTP logic above.
if w.UUID == "" {
set.Webhooks[i].UUID = uuid.Must(uuid.NewV4()).String()
}

// If there's no password/token coming in from the frontend, copy the existing
// values by matching the UUID.
if w.AuthBasicPass == "" {
for _, c := range cur.Webhooks {
if w.UUID == c.UUID {
set.Webhooks[i].AuthBasicPass = c.AuthBasicPass
}
}
}
if w.AuthToken == "" {
for _, c := range cur.Webhooks {
if w.UUID == c.UUID {
set.Webhooks[i].AuthToken = c.AuthToken
}
}
}
}

// S3 password?
if set.UploadS3AwsSecretAccessKey == "" {
set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey
Expand Down
1 change: 1 addition & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var migList = []migFunc{
{"v5.0.0", migrations.V5_0_0},
{"v5.1.0", migrations.V5_1_0},
{"v6.0.0", migrations.V6_0_0},
{"v6.1.0", migrations.V6_1_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
13 changes: 13 additions & 0 deletions cmd/webhooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package main

import (
"net/http"

"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)

// GetWebhookEvents returns the list of available webhook events.
func (a *App) GetWebhookEvents(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{models.AllWebhookEvents()})
}
6 changes: 6 additions & 0 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,9 @@ export const disableTOTP = (id, data) => http.delete(
`/api/users/${id}/twofa`,
{ data },
);

// Webhooks.
export const getWebhookEvents = async () => http.get(
'/api/settings/webhooks/events',
{ camelCase: false },
);
30 changes: 30 additions & 0 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@
<messenger-settings :form="form" :key="key" />
</b-tab-item><!-- messengers -->

<b-tab-item :label="$t('settings.webhooks.name')">
<webhook-settings :form="form" :events="webhookEvents" :key="key" />
</b-tab-item><!-- webhooks -->

<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->
Expand All @@ -75,6 +79,7 @@ import PerformanceSettings from './settings/performance.vue';
import PrivacySettings from './settings/privacy.vue';
import SecuritySettings from './settings/security.vue';
import SmtpSettings from './settings/smtp.vue';
import WebhookSettings from './settings/webhooks.vue';

export default Vue.extend({
components: {
Expand All @@ -86,6 +91,7 @@ export default Vue.extend({
SmtpSettings,
BounceSettings,
MessengerSettings,
WebhookSettings,
AppearanceSettings,
},

Expand All @@ -102,6 +108,7 @@ export default Vue.extend({
formCopy: '',
form: null,
tab: 0,
webhookEvents: [],
};
},

Expand Down Expand Up @@ -187,6 +194,22 @@ export default Vue.extend({
}
}

// Webhook secrets.
for (let i = 0; i < form.webhooks.length; i += 1) {
// If it's the dummy UI password placeholder, ignore it.
if (this.isDummy(form.webhooks[i].auth_basic_pass)) {
form.webhooks[i].auth_basic_pass = '';
} else if (this.hasDummy(form.webhooks[i].auth_basic_pass)) {
hasDummy = `webhook #${i + 1} password`;
}

if (this.isDummy(form.webhooks[i].auth_hmac_secret)) {
form.webhooks[i].auth_hmac_secret = '';
} else if (this.hasDummy(form.webhooks[i].auth_hmac_secret)) {
hasDummy = `webhook #${i + 1} HMAC secret`;
}
}

if (hasDummy) {
this.$utils.toast(this.$t('globals.messages.passwordChangeFull', { name: hasDummy }), 'is-danger');
return false;
Expand Down Expand Up @@ -245,6 +268,12 @@ export default Vue.extend({
hasDummy(pwd) {
return pwd.includes('•');
},

getWebhookEvents() {
this.$api.getWebhookEvents().then((data) => {
this.webhookEvents = data;
});
},
},

computed: {
Expand All @@ -269,6 +298,7 @@ export default Vue.extend({
mounted() {
this.tab = this.$utils.getPref('settings.tab') || 0;
this.getSettings();
this.getWebhookEvents();
},

watch: {
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/views/settings/performance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@
min="0" max="100000" />
</b-field>

<hr />
<div class="columns">
<div class="column is-6">
<b-field :label="$t('settings.performance.webhookWorkers')" label-position="on-border"
:message="$t('settings.performance.webhookWorkersHelp')">
<b-numberinput v-model="data['app.webhook_workers']" name="app.webhook_workers" type="is-light" placeholder="2"
min="1" max="100" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('settings.performance.webhookBatchSize')" label-position="on-border"
:message="$t('settings.performance.webhookBatchSizeHelp')">
<b-numberinput v-model="data['app.webhook_batch_size']" name="app.webhook_batch_size" type="is-light"
placeholder="50" min="1" max="1000" />
</b-field>
</div>
</div>

<div>
<div class="columns">
<div class="column is-6">
Expand Down
Loading