diff --git a/cmd/init.go b/cmd/init.go
index 24a1af377..4c5acb89c 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -529,7 +529,14 @@ 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),
+ fnSendTx func(models.TxMessage) error,
+ queries *models.Queries,
+ db *sqlx.DB,
+ i *i18n.I18n,
+ ko *koanf.Koanf,
+) *core.Core {
opt := &core.Opt{
Constants: core.Constants{
SendOptinConfirmation: ko.Bool("app.send_optin_confirmation"),
@@ -549,6 +556,7 @@ func initCore(fnNotify func(sub models.Subscriber, listIDs []int) (int, error),
// Initialize the CRUD core.
return core.New(opt, &core.Hooks{
SendOptinConfirmation: fnNotify,
+ SendTxMessage: fnSendTx,
})
}
diff --git a/cmd/install.go b/cmd/install.go
index ebe6da67b..7db51854e 100644
--- a/cmd/install.go
+++ b/cmd/install.go
@@ -145,6 +145,7 @@ func installLists(q *models.Queries) (int, int) {
models.ListStatusActive,
pq.StringArray{"test"},
"",
+ nil,
); err != nil {
lo.Fatalf("error creating list: %v", err)
}
@@ -156,6 +157,7 @@ func installLists(q *models.Queries) (int, int) {
models.ListStatusActive,
pq.StringArray{"test"},
"",
+ nil,
); err != nil {
lo.Fatalf("error creating list: %v", err)
}
diff --git a/cmd/main.go b/cmd/main.go
index c10f658db..96fdd4929 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -167,8 +167,18 @@ func init() {
queries = prepareQueries(qMap, db, ko)
}
+type appRef struct {
+ App *App
+}
+
+func (s *appRef) sendTxMessage(tx models.TxMessage) error {
+ return s.App.sendTxMessage(tx)
+}
+
func main() {
var (
+ app *App
+
// Initialize static global config.
cfg = initConstConfig(ko)
@@ -183,8 +193,10 @@ func main() {
fbOptinNotify = makeOptinNotifyHook(ko.Bool("privacy.unsubscribe_header"), urlCfg, queries, i18n)
+ appRef = &appRef{}
+
// Crud core.
- core = initCore(fbOptinNotify, queries, db, i18n, ko)
+ core = initCore(fbOptinNotify, appRef.sendTxMessage, queries, db, i18n, ko)
// Initialize all messengers, SMTP and postback.
msgrs = append(initSMTPMessengers(), initPostbackMessengers(ko)...)
@@ -242,7 +254,7 @@ func main() {
// =========================================================================
// Initialize the App{} with all the global shared components, controllers and fields.
- app := &App{
+ app = &App{
cfg: cfg,
urlCfg: urlCfg,
fs: fs,
@@ -279,6 +291,8 @@ func main() {
needsUserSetup: !hasUsers,
}
+ appRef.App = app
+
// Star the update checker.
if ko.Bool("app.check_updates") {
go app.checkUpdates(versionString, time.Hour*24)
diff --git a/cmd/tx.go b/cmd/tx.go
index 3b6314140..dd479fb70 100644
--- a/cmd/tx.go
+++ b/cmd/tx.go
@@ -62,6 +62,18 @@ func (a *App) SendTxMessage(c echo.Context) error {
return err
}
+ if err := a.sendTxMessage(m); err != nil {
+ return err
+ }
+
+ return c.JSON(http.StatusOK, okResp{true})
+}
+
+func (a *App) sendTxMessage(m models.TxMessage) error {
+ if a == nil {
+ return fmt.Errorf("app is not initialized")
+ }
+
// Validate fields.
if r, err := a.validateTxMessage(m); err != nil {
return err
@@ -108,7 +120,6 @@ func (a *App) SendTxMessage(c echo.Context) error {
subEmail = m.SubscriberEmails[n]
}
- var err error
sub, err = a.core.GetSubscriber(subID, "", subEmail)
if err != nil {
if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
@@ -171,7 +182,7 @@ func (a *App) SendTxMessage(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
}
- return c.JSON(http.StatusOK, okResp{true})
+ return nil
}
// validateTxMessage validates the tx message fields.
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index 67e01e440..7e7dd4936 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -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.2.1", migrations.V5_2_1},
}
// upgrade upgrades the database to the current version by running SQL migration files
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index d326cba5f..b83716ff3 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -249,3 +249,35 @@ export default class Utils {
localStorage.setItem(prefKey, JSON.stringify(p));
};
}
+
+export function snakeString(str) {
+ return str.replace(/[A-Z]/g, (match, offset) => (offset ? '_' : '') + match.toLowerCase());
+}
+
+export function snakeKeys(obj, testFunc, keys) {
+ if (obj === null) {
+ return obj;
+ }
+
+ if (Array.isArray(obj)) {
+ return obj.map((o) => snakeKeys(o, testFunc, `${keys || ''}.*`));
+ }
+
+ if (obj.constructor === Object) {
+ return Object.keys(obj).reduce((result, key) => {
+ const keyPath = `${keys || ''}.${key}`;
+ let k = key;
+
+ // If there's no testfunc or if a function is defined and it returns true, convert.
+ if (testFunc === undefined || testFunc(keyPath)) {
+ k = snakeString(key);
+ }
+
+ return {
+ ...result,
+ [k]: snakeKeys(obj[key], testFunc, keyPath),
+ };
+ }, {});
+ }
+ return obj;
+}
diff --git a/frontend/src/views/ListForm.vue b/frontend/src/views/ListForm.vue
index 3b960406a..1394340a5 100644
--- a/frontend/src/views/ListForm.vue
+++ b/frontend/src/views/ListForm.vue
@@ -44,6 +44,17 @@
+
+
+
+
+
+
+
+
+
@@ -75,6 +86,7 @@
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
+import { snakeKeys } from '../utils';
export default Vue.extend({
name: 'ListForm',
@@ -97,6 +109,7 @@ export default Vue.extend({
optin: 'single',
status: 'active',
tags: [],
+ welcomeTemplateId: null,
},
};
},
@@ -112,7 +125,7 @@ export default Vue.extend({
},
createList() {
- this.$api.createList(this.form).then((data) => {
+ this.$api.createList(snakeKeys(this.form)).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@@ -120,7 +133,8 @@ export default Vue.extend({
},
updateList() {
- this.$api.updateList({ id: this.data.id, ...this.form }).then((data) => {
+ const form = snakeKeys(this.form);
+ this.$api.updateList({ id: this.data.id, ...form }).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
@@ -129,7 +143,7 @@ export default Vue.extend({
},
computed: {
- ...mapState(['loading', 'profile']),
+ ...mapState(['loading', 'profile', 'templates']),
isArchived: {
get() {
@@ -144,6 +158,9 @@ export default Vue.extend({
mounted() {
this.form = { ...this.form, ...this.$props.data };
+ // Get the templates list.
+ this.$api.getTemplates();
+
this.$nextTick(() => {
this.$refs.focus.focus();
});
diff --git a/i18n/en.json b/i18n/en.json
index e6c4bd50f..19c32529b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -296,6 +296,8 @@
"lists.typeHelp": "Public lists are open to the world to subscribe and their names may appear on public pages such as the subscription management page.",
"lists.types.private": "Private",
"lists.types.public": "Public",
+ "lists.welcomeTemplate": "Welcome Template",
+ "lists.welcomeTemplateHelp": "If enabled, sends an e-mail to new confirmed subscribers using the selected template.",
"logs.title": "Logs",
"maintenance.help": "Some actions may take a while to complete depending on the amount of data.",
"maintenance.maintenance.unconfirmedOptins": "Unconfirmed opt-in subscriptions",
diff --git a/internal/core/core.go b/internal/core/core.go
index c612e5766..473dd593e 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -52,6 +52,7 @@ type Constants struct {
// Hooks contains external function hooks that are required by the core package.
type Hooks struct {
SendOptinConfirmation func(models.Subscriber, []int) (int, error)
+ SendTxMessage func(models.TxMessage) error
}
// Opt contains the controllers required to start the core.
diff --git a/internal/core/lists.go b/internal/core/lists.go
index fb09f19d4..207b19170 100644
--- a/internal/core/lists.go
+++ b/internal/core/lists.go
@@ -167,7 +167,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// Insert and read ID.
var newID int
l.UUID = uu.String()
- if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil {
+ if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID); err != nil {
c.log.Printf("error creating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
@@ -178,7 +178,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// UpdateList updates a given list.
func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
- res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description)
+ res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID)
if err != nil {
c.log.Printf("error updating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
diff --git a/internal/core/subscribers.go b/internal/core/subscribers.go
index 95332f617..98cceedf3 100644
--- a/internal/core/subscribers.go
+++ b/internal/core/subscribers.go
@@ -345,6 +345,9 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
hasOptin = num > 0
}
+ // Send welcome messages where applicable.
+ c.sendWelcomeMessage(sub.UUID, map[int]bool{})
+
return out, hasOptin, nil
}
@@ -391,6 +394,13 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
subStatus = models.SubscriptionStatusConfirmed
}
+ // Track welcome e-mails already sent before the update.
+ existingSub, err := c.GetSubscriber(id, "", "")
+ if err != nil {
+ return models.Subscriber{}, false, err
+ }
+ welcomesSent := c.getWelcomesSent(existingSub.UUID)
+
// Format raw JSON attributes.
attribs := []byte("{}")
if len(sub.Attribs) > 0 {
@@ -403,7 +413,7 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
}
}
- _, err := c.q.UpdateSubscriberWithLists.Exec(id,
+ _, err = c.q.UpdateSubscriberWithLists.Exec(id,
sub.Email,
strings.TrimSpace(sub.Name),
sub.Status,
@@ -418,7 +428,7 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
- out, err := c.GetSubscriber(sub.ID, "", sub.Email)
+ out, err := c.GetSubscriber(id, "", sub.Email)
if err != nil {
return models.Subscriber{}, false, err
}
@@ -426,13 +436,16 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
hasOptin := false
if !preconfirm && c.consts.SendOptinConfirmation {
// Send a confirmation e-mail (if there are any double opt-in lists).
- num, err := c.h.SendOptinConfirmation(out, listIDs)
- if assertOptin && err != nil {
- return out, hasOptin, err
+ num, sendErr := c.h.SendOptinConfirmation(out, listIDs)
+ if assertOptin && sendErr != nil {
+ return out, hasOptin, sendErr
}
hasOptin = num > 0
}
+ // Send welcome messages where applicable.
+ c.sendWelcomeMessage(out.UUID, welcomesSent)
+
return out, hasOptin, nil
}
@@ -505,12 +518,17 @@ func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, met
meta = models.JSON{}
}
+ // Track welcome e-mails already sent before the update.
+ welcomesSent := c.getWelcomesSent(subUUID)
+
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
c.log.Printf("error confirming subscription: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
+ // Send welcome messages where applicable.
+ c.sendWelcomeMessage(subUUID, welcomesSent)
return nil
}
@@ -556,6 +574,73 @@ func (c *Core) DeleteBlocklistedSubscribers() (int, error) {
return int(n), nil
}
+func (c *Core) getWelcomesSent(subUUID string) map[int]bool {
+ welcomesSent := map[int]bool{}
+ listSubs, err := c.GetSubscriptions(0, subUUID, false)
+ if err != nil {
+ return welcomesSent
+ }
+
+ for _, listSub := range listSubs {
+ if listSub.WelcomeTemplateID == nil {
+ continue
+ }
+ if !(listSub.Optin == models.ListOptinSingle || listSub.SubscriptionStatus.String == models.SubscriptionStatusConfirmed) {
+ continue
+ }
+ welcomesSent[listSub.ID] = true
+ }
+
+ return welcomesSent
+}
+
+func (c *Core) sendWelcomeMessage(subUUID string, welcomesSent map[int]bool) {
+ if c.h == nil || c.h.SendTxMessage == nil {
+ return
+ }
+
+ listSubs, err := c.GetSubscriptions(0, subUUID, false)
+ if err != nil {
+ c.log.Printf("error getting the subscriber's lists: %v", err)
+ return
+ }
+
+ sub, err := c.GetSubscriber(0, subUUID, "")
+ if err != nil {
+ c.log.Printf("error sending welcome messages: subscriber not found %v", err)
+ return
+ }
+
+ for _, listSub := range listSubs {
+ if listSub.WelcomeTemplateID == nil {
+ continue
+ }
+ if welcomesSent[listSub.ID] {
+ continue
+ }
+ if !(listSub.Optin == models.ListOptinSingle || listSub.SubscriptionStatus.String == models.SubscriptionStatusConfirmed) {
+ continue
+ }
+
+ data := map[string]any{}
+ if len(listSub.Meta) > 0 {
+ err := json.Unmarshal(listSub.Meta, &data)
+ if err != nil {
+ c.log.Printf("error unmarshalling sub meta: %v", err)
+ }
+ }
+
+ err = c.h.SendTxMessage(models.TxMessage{
+ TemplateID: *listSub.WelcomeTemplateID,
+ SubscriberIDs: []int{sub.ID},
+ Data: data,
+ })
+ if err != nil {
+ c.log.Printf("error sending welcome messages: %v", err)
+ }
+ }
+}
+
func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string, listIDs []int) (int, error) {
// If there's no condition, it's a "get all" call which can probably be optionally pulled from cache.
if queryExp == "" {
diff --git a/internal/migrations/v5.2.1.go b/internal/migrations/v5.2.1.go
new file mode 100644
index 000000000..97bf8f0b4
--- /dev/null
+++ b/internal/migrations/v5.2.1.go
@@ -0,0 +1,21 @@
+package migrations
+
+import (
+ "log"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/koanf/v2"
+ "github.com/knadh/stuffbin"
+)
+
+// V5_2_1 performs the DB migrations.
+func V5_2_1(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
+ if _, err := db.Exec(`
+ ALTER TABLE lists ADD COLUMN IF NOT EXISTS welcome_template_id INTEGER NULL
+ REFERENCES templates(id) ON DELETE SET NULL ON UPDATE CASCADE;
+ `); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/models/lists.go b/models/lists.go
index 486a1eacd..aa129e387 100644
--- a/models/lists.go
+++ b/models/lists.go
@@ -29,6 +29,8 @@ type List struct {
SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
SubscriberID int `db:"subscriber_id" json:"-"`
+ WelcomeTemplateID *int `db:"welcome_template_id" json:"welcome_template_id"`
+
// This is only relevant when querying the lists of a subscriber.
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"`
diff --git a/queries/lists.sql b/queries/lists.sql
index 673b1c672..cdc4d4b6d 100644
--- a/queries/lists.sql
+++ b/queries/lists.sql
@@ -53,7 +53,7 @@ SELECT id, uuid, type FROM lists WHERE
END);
-- name: create-list
-INSERT INTO lists (uuid, name, type, optin, status, tags, description) VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING id;
+INSERT INTO lists (uuid, name, type, optin, status, tags, description, welcome_template_id) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id;
-- name: update-list
WITH l AS (
@@ -64,6 +64,7 @@ WITH l AS (
status=(CASE WHEN $5 != '' THEN $5::list_status ELSE status END),
tags=$6::VARCHAR(100)[],
description=(CASE WHEN $7 != '' THEN $7 ELSE description END),
+ welcome_template_id=$8,
updated_at=NOW()
WHERE id = $1
RETURNING id, name
diff --git a/schema.sql b/schema.sql
index 09735051e..e0c58b985 100644
--- a/schema.sql
+++ b/schema.sql
@@ -34,6 +34,22 @@ DROP INDEX IF EXISTS idx_subs_id_status; CREATE INDEX idx_subs_id_status ON subs
DROP INDEX IF EXISTS idx_subs_created_at; CREATE INDEX idx_subs_created_at ON subscribers(created_at);
DROP INDEX IF EXISTS idx_subs_updated_at; CREATE INDEX idx_subs_updated_at ON subscribers(updated_at);
+-- templates
+DROP TABLE IF EXISTS templates CASCADE;
+CREATE TABLE templates (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ type template_type NOT NULL DEFAULT 'campaign',
+ subject TEXT NOT NULL,
+ body TEXT NOT NULL,
+ body_source TEXT NULL,
+ is_default BOOLEAN NOT NULL DEFAULT false,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true;
+
-- lists
DROP TABLE IF EXISTS lists CASCADE;
CREATE TABLE lists (
@@ -45,6 +61,7 @@ CREATE TABLE lists (
status list_status NOT NULL DEFAULT 'active',
tags VARCHAR(100)[],
description TEXT NOT NULL DEFAULT '',
+ welcome_template_id INTEGER NULL REFERENCES templates(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -73,23 +90,6 @@ DROP INDEX IF EXISTS idx_sub_lists_sub_id; CREATE INDEX idx_sub_lists_sub_id ON
DROP INDEX IF EXISTS idx_sub_lists_list_id; CREATE INDEX idx_sub_lists_list_id ON subscriber_lists(list_id);
DROP INDEX IF EXISTS idx_sub_lists_status; CREATE INDEX idx_sub_lists_status ON subscriber_lists(status);
--- templates
-DROP TABLE IF EXISTS templates CASCADE;
-CREATE TABLE templates (
- id SERIAL PRIMARY KEY,
- name TEXT NOT NULL,
- type template_type NOT NULL DEFAULT 'campaign',
- subject TEXT NOT NULL,
- body TEXT NOT NULL,
- body_source TEXT NULL,
- is_default BOOLEAN NOT NULL DEFAULT false,
-
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-);
-CREATE UNIQUE INDEX ON templates (is_default) WHERE is_default = true;
-
-
-- campaigns
DROP TABLE IF EXISTS campaigns CASCADE;
CREATE TABLE campaigns (