Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions 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 @@ -548,6 +549,9 @@ func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error),
lo.Fatalf("error unmarshalling bounce config: %v", err)
}

// Initialize webhook client.
opt.Webhooks = initWebhooks(ko)

// Initialize the CRUD core.
return core.New(opt, &core.Hooks{
SendOptinConfirmation: fnNotify,
Expand Down Expand Up @@ -1143,3 +1147,23 @@ func joinFSPaths(root string, paths []string) []string {

return out
}

// initWebhooks initializes the webhook client with settings from the config.
func initWebhooks(ko *koanf.Koanf) *webhooks.Client {
client := webhooks.New(lo)

// Configure subscription confirmed webhook.
var subConfirmed webhooks.Config
if err := ko.Unmarshal("webhooks.subscription_confirmed", &subConfirmed); err != nil {
lo.Printf("error loading webhooks.subscription_confirmed config: %v", err)
return client
}

client.SetConfig(webhooks.EventSubscriptionConfirmed, subConfirmed)

if subConfirmed.Enabled && subConfirmed.URL != "" {
lo.Printf("initialized subscription confirmed webhook: %s", subConfirmed.URL)
}

return client
}
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},
{"v5.2.0", migrations.V5_2_0},
{"v5.3.0", migrations.V5_3_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
<b-tab-item :label="$t('settings.appearance.name')">
<appearance-settings :form="form" :key="key" />
</b-tab-item><!-- appearance -->

<b-tab-item :label="$t('settings.webhooks.name')">
<webhook-settings :form="form" :key="key" />
</b-tab-item><!-- webhooks -->
</b-tabs>
</section>
</section>
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 @@ -87,6 +92,7 @@ export default Vue.extend({
BounceSettings,
MessengerSettings,
AppearanceSettings,
WebhookSettings,
},

data() {
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/views/settings/webhooks.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<template>
<div>
<div class="columns">
<div class="column is-6">
<p class="has-text-grey">
{{ $t('settings.webhooks.help') }}
</p>
</div>
</div>

<hr />

<!-- Subscription Confirmed Webhook -->
<div class="columns mb-6">
<div class="column is-12">
<h4 class="title is-5">{{ $t('settings.webhooks.subscriptionConfirmed') }}</h4>
<p class="has-text-grey mb-4">{{ $t('settings.webhooks.subscriptionConfirmedHelp') }}</p>

<b-field :label="$t('globals.buttons.enable')">
<b-switch v-model="data['webhooks'].subscription_confirmed.enabled"
name="webhooks_subscription_enabled" />
</b-field>

<div class="box" v-if="data['webhooks'].subscription_confirmed.enabled">
<div class="columns">
<div class="column is-8">
<b-field :label="$t('globals.terms.url')" label-position="on-border">
<b-input v-model="data['webhooks'].subscription_confirmed.url"
name="webhooks_subscription_url"
placeholder="http://your-server.com/webhook/subscription"
type="url" />
</b-field>
</div>
</div>

<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.webhooks.timeout')" label-position="on-border"
:message="$t('settings.webhooks.timeoutHelp')">
<b-input v-model="data['webhooks'].subscription_confirmed.timeout"
name="webhooks_timeout" placeholder="10s" :pattern="regDuration" />
</b-field>
</div>
<div class="column is-3">
<b-field :label="$t('settings.webhooks.maxRetries')" label-position="on-border"
:message="$t('settings.webhooks.maxRetriesHelp')">
<b-numberinput v-model="data['webhooks'].subscription_confirmed.max_retries"
name="webhooks_max_retries" type="is-light" controls-position="compact"
min="1" max="10" />
</b-field>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<script>
import Vue from 'vue';
import { regDuration } from '../../constants';

export default Vue.extend({
props: {
form: {
type: Object, default: () => { },
},
},

data() {
return {
data: this.form,
regDuration,
};
},
});
</script>
12 changes: 10 additions & 2 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,5 +688,13 @@
"lists.archived": "Archived",
"lists.archivedHelp": "Archiving hides the lists from lists page, campaigns, and public forms. It can be unarchived anytime. It is useful for hiding old and rarely used lists.",
"maintenance.database.title": "Database",
"maintenance.database.vacuumHelp": "PostgreSQL VACUUM ANALYZE reclaims storage used by deleted rows and significantly speeds up database performance on large databases. IMPORTANT: For large databases, this is a slow, blocking operation. Schedule to run this during off-peak hours."
}
"maintenance.database.vacuumHelp": "PostgreSQL VACUUM ANALYZE reclaims storage used by deleted rows and significantly speeds up database performance on large databases. IMPORTANT: For large databases, this is a slow, blocking operation. Schedule to run this during off-peak hours.",
"settings.webhooks.name": "Webhooks",
"settings.webhooks.help": "Configure outgoing webhooks that fire on specific events. Your server will receive HTTP POST requests with event data.",
"settings.webhooks.subscriptionConfirmed": "Subscription confirmed",
"settings.webhooks.subscriptionConfirmedHelp": "Fires when a subscriber confirms their subscription (double opt-in) or is added with single opt-in.",
"settings.webhooks.timeout": "Timeout",
"settings.webhooks.timeoutHelp": "Request timeout (e.g., 10s, 30s)",
"settings.webhooks.maxRetries": "Max retries",
"settings.webhooks.maxRetriesHelp": "Number of retry attempts on failure"
}
26 changes: 15 additions & 11 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/webhooks"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
Expand All @@ -32,11 +33,12 @@ const (
type Core struct {
h *Hooks

consts Constants
i18n *i18n.I18n
db *sqlx.DB
q *models.Queries
log *log.Logger
consts Constants
i18n *i18n.I18n
db *sqlx.DB
q *models.Queries
log *log.Logger
webhooks *webhooks.Client
}

// Constants represents constant config.
Expand All @@ -61,6 +63,7 @@ type Opt struct {
DB *sqlx.DB
Queries *models.Queries
Log *log.Logger
Webhooks *webhooks.Client
}

var (
Expand All @@ -78,12 +81,13 @@ var (
// New returns a new instance of the core.
func New(o *Opt, h *Hooks) *Core {
return &Core{
h: h,
consts: o.Constants,
i18n: o.I18n,
db: o.DB,
q: o.Queries,
log: o.Log,
h: h,
consts: o.Constants,
i18n: o.I18n,
db: o.DB,
q: o.Queries,
log: o.Log,
webhooks: o.Webhooks,
}
}

Expand Down
66 changes: 66 additions & 0 deletions internal/core/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/webhooks"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
Expand Down Expand Up @@ -345,6 +346,12 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
hasOptin = num > 0
}

// For preconfirmed (single opt-in) subscriptions, fire webhook immediately.
// For double opt-in, the webhook fires in ConfirmOptionSubscription after user confirms.
if preconfirm && (len(listIDs) > 0 || len(listUUIDs) > 0) {
c.fireSubscriptionConfirmedWebhookByID(out, listIDs, listUUIDs, nil)
}

return out, hasOptin, nil
}

Expand Down Expand Up @@ -433,6 +440,11 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
hasOptin = num > 0
}

// For preconfirmed (single opt-in) subscriptions, fire webhook immediately.
if preconfirm && (len(listIDs) > 0 || len(listUUIDs) > 0) {
c.fireSubscriptionConfirmedWebhookByID(out, listIDs, listUUIDs, nil)
}

return out, hasOptin, nil
}

Expand Down Expand Up @@ -511,6 +523,9 @@ func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, met
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}

// Fire subscription confirmed webhook asynchronously.
c.fireSubscriptionConfirmedWebhook(subUUID, listUUIDs, meta)

return nil
}

Expand Down Expand Up @@ -659,3 +674,54 @@ func traverseQueryPlan(node map[string]any, tables map[string]struct{}) {
}
}
}

// fireSubscriptionConfirmedWebhook fires the subscription confirmed webhook asynchronously.
// It fetches the subscriber and list data, then fires the webhook in a goroutine.
func (c *Core) fireSubscriptionConfirmedWebhook(subUUID string, listUUIDs []string, meta models.JSON) {
if c.webhooks == nil || !c.webhooks.IsEnabled(webhooks.EventSubscriptionConfirmed) {
return
}

// Fire in goroutine to avoid blocking the request.
go func() {
// Fetch subscriber data.
sub, err := c.GetSubscriber(0, subUUID, "")
if err != nil {
c.log.Printf("error fetching subscriber for webhook: %v", err)
return
}

// Fetch list data.
lists, err := c.GetSubscriberLists(0, subUUID, nil, listUUIDs, models.SubscriptionStatusConfirmed, "")
if err != nil {
c.log.Printf("error fetching lists for webhook: %v", err)
return
}

// Fire the webhook.
event := webhooks.NewSubscriptionConfirmedEvent(sub, lists, meta)
c.webhooks.Fire(event)
}()
}

// fireSubscriptionConfirmedWebhookByID fires the subscription confirmed webhook for a known subscriber.
// Used for single opt-in subscriptions where we already have the subscriber object.
func (c *Core) fireSubscriptionConfirmedWebhookByID(sub models.Subscriber, listIDs []int, listUUIDs []string, meta models.JSON) {
if c.webhooks == nil || !c.webhooks.IsEnabled(webhooks.EventSubscriptionConfirmed) {
return
}

// Fire in goroutine to avoid blocking the request.
go func() {
// Fetch list data for the confirmed lists.
lists, err := c.GetSubscriberLists(sub.ID, "", listIDs, listUUIDs, models.SubscriptionStatusConfirmed, "")
if err != nil {
c.log.Printf("error fetching lists for webhook: %v", err)
return
}

// Fire the webhook.
event := webhooks.NewSubscriptionConfirmedEvent(sub, lists, meta)
c.webhooks.Fire(event)
}()
}
25 changes: 25 additions & 0 deletions internal/migrations/v5.3.0.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package migrations

import (
"log"

"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)

// V5_3_0 adds webhook settings for subscription events.
func V5_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
lo.Println("v5.3.0: adding webhook settings...")

_, err := db.Exec(`
INSERT INTO settings (key, value, updated_at)
VALUES ('webhooks', '{"subscription_confirmed": {"enabled": false, "url": "", "timeout": "10s", "max_retries": 3}}', NOW())
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}

return nil
}
39 changes: 39 additions & 0 deletions internal/webhooks/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package webhooks

import (
"time"

"github.com/knadh/listmonk/models"
)

type EventType string

const (
// EventSubscriptionConfirmed is fired when a subscription is confirmed
// (either immediately for single opt-in, or after clicking confirmation link for double opt-in).
EventSubscriptionConfirmed EventType = "subscription.confirmed"
)

type Event struct {
Event EventType `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data EventData `json:"data"`
}

type EventData struct {
Subscriber models.Subscriber `json:"subscriber"`
Lists []models.List `json:"lists"`
Meta models.JSON `json:"meta,omitempty"`
}

func NewSubscriptionConfirmedEvent(sub models.Subscriber, lists []models.List, meta models.JSON) Event {
return Event{
Event: EventSubscriptionConfirmed,
Timestamp: time.Now(),
Data: EventData{
Subscriber: sub,
Lists: lists,
Meta: meta,
},
}
}
Loading