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 (