Compare commits

...

3 commits

Author SHA1 Message Date
Hiram Chirino c7880d6fe8
Merge 5d89b38138 into 550cd3e1f8 2024-09-12 11:35:32 +02:00
Bishop Clark 550cd3e1f8
Update README.md (#2034)
'software', when used as a noun, is not a 'countable' type, and it does not get an article like 'a'.  It's like 'traffic'.
2024-09-06 15:49:53 +05:30
Hiram Chirino 5d89b38138 feat: support configuring a welcome email on lists.
This welcome template will be used to send a welcome email to new list members once the subscription is confirmed (if confirmation is required).

Signed-off-by: Hiram Chirino <hiram@hiramchirino.com>
2024-04-06 11:28:55 -04:00
16 changed files with 190 additions and 25 deletions

View file

@ -48,7 +48,7 @@ __________________
## Developers ## Developers
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.
## License ## License

View file

@ -74,6 +74,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
models.ListOptinSingle, models.ListOptinSingle,
pq.StringArray{"test"}, pq.StringArray{"test"},
"", "",
nil,
); err != nil { ); err != nil {
lo.Fatalf("error creating list: %v", err) lo.Fatalf("error creating list: %v", err)
} }
@ -84,6 +85,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
models.ListOptinDouble, models.ListOptinDouble,
pq.StringArray{"test"}, pq.StringArray{"test"},
"", "",
nil,
); err != nil { ); err != nil {
lo.Fatalf("error creating list: %v", err) lo.Fatalf("error creating list: %v", err)
} }

View file

@ -205,6 +205,7 @@ func main() {
app.core = core.New(cOpt, &core.Hooks{ app.core = core.New(cOpt, &core.Hooks{
SendOptinConfirmation: sendOptinConfirmationHook(app), SendOptinConfirmation: sendOptinConfirmationHook(app),
SendTxMessage: sendTxMessageHook(app),
}) })
app.queries = queries app.queries = queries

View file

@ -631,3 +631,11 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
return len(lists), nil return len(lists), nil
} }
} }
// sendTxMessageHook returns an enclosed callback that sends tx e-mails.
// This is plugged into the 'core' package to send welcome messages when a new subscriber is confirmed.
func sendTxMessageHook(app *App) func(tx models.TxMessage) error {
return func(tx models.TxMessage) error {
return sendTxMessage(app, tx)
}
}

View file

@ -66,6 +66,15 @@ func handleSendTxMessage(c echo.Context) error {
return err return err
} }
err := sendTxMessage(app, m)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
func sendTxMessage(app *App, m models.TxMessage) error {
// Validate input. // Validate input.
if r, err := validateTxMessage(m, app); err != nil { if r, err := validateTxMessage(m, app); err != nil {
return err return err
@ -156,8 +165,7 @@ func handleSendTxMessage(c echo.Context) error {
if len(notFound) > 0 { if len(notFound) > 0 {
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; ")) return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
} }
return nil
return c.JSON(http.StatusOK, okResp{true})
} }
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) { func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {

View file

@ -38,6 +38,7 @@ var migList = []migFunc{
{"v2.4.0", migrations.V2_4_0}, {"v2.4.0", migrations.V2_4_0},
{"v2.5.0", migrations.V2_5_0}, {"v2.5.0", migrations.V2_5_0},
{"v3.0.0", migrations.V3_0_0}, {"v3.0.0", migrations.V3_0_0},
{"v3.1.0", migrations.V3_1_0},
} }
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files

View file

@ -235,3 +235,34 @@ export default class Utils {
localStorage.setItem(prefKey, JSON.stringify(p)); 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;
}

View file

@ -44,6 +44,17 @@
</b-select> </b-select>
</b-field> </b-field>
<b-field :label="$tc('lists.welcomeTemplate')" label-position="on-border" :message="$t('lists.welcomeTemplateHelp')">
<b-select v-model="form.welcomeTemplateId" name="template">
<option :value="null">{{ $tc('globals.terms.none') }}</option>
<template v-for="t in templates">
<option v-if="t.type === 'tx'" :value="t.id" :key="t.id">
{{ t.name }}
</option>
</template>
</b-select>
</b-field>
<b-field :label="$t('globals.terms.tags')" label-position="on-border"> <b-field :label="$t('globals.terms.tags')" label-position="on-border">
<b-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline" <b-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline"
:placeholder="$t('globals.terms.tags')" /> :placeholder="$t('globals.terms.tags')" />
@ -70,6 +81,7 @@
import Vue from 'vue'; import Vue from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue'; import CopyText from '../components/CopyText.vue';
import { snakeKeys } from '../utils';
export default Vue.extend({ export default Vue.extend({
name: 'ListForm', name: 'ListForm',
@ -91,6 +103,7 @@ export default Vue.extend({
type: 'private', type: 'private',
optin: 'single', optin: 'single',
tags: [], tags: [],
welcomeTemplateId: null,
}, },
}; };
}, },
@ -106,7 +119,7 @@ export default Vue.extend({
}, },
createList() { createList() {
this.$api.createList(this.form).then((data) => { this.$api.createList(snakeKeys(this.form)).then((data) => {
this.$emit('finished'); this.$emit('finished');
this.$parent.close(); this.$parent.close();
this.$utils.toast(this.$t('globals.messages.created', { name: data.name })); this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
@ -114,7 +127,8 @@ export default Vue.extend({
}, },
updateList() { 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.$emit('finished');
this.$parent.close(); this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name })); this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
@ -123,12 +137,15 @@ export default Vue.extend({
}, },
computed: { computed: {
...mapState(['loading']), ...mapState(['loading', 'templates']),
}, },
mounted() { mounted() {
this.form = { ...this.form, ...this.$props.data }; this.form = { ...this.form, ...this.$props.data };
// Get the templates list.
this.$api.getTemplates();
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.focus.focus(); this.$refs.focus.focus();
}); });

View file

@ -276,6 +276,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.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.private": "Private",
"lists.types.public": "Public", "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", "logs.title": "Logs",
"maintenance.help": "Some actions may take a while to complete depending on the amount of data.", "maintenance.help": "Some actions may take a while to complete depending on the amount of data.",
"maintenance.maintenance.unconfirmedOptins": "Unconfirmed opt-in subscriptions", "maintenance.maintenance.unconfirmedOptins": "Unconfirmed opt-in subscriptions",

View file

@ -50,6 +50,7 @@ type Constants struct {
// Hooks contains external function hooks that are required by the core package. // Hooks contains external function hooks that are required by the core package.
type Hooks struct { type Hooks struct {
SendOptinConfirmation func(models.Subscriber, []int) (int, error) SendOptinConfirmation func(models.Subscriber, []int) (int, error)
SendTxMessage func(tx models.TxMessage) error
} }
// Opt contains the controllers required to start the core. // Opt contains the controllers required to start the core.

View file

@ -136,7 +136,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// Insert and read ID. // Insert and read ID.
var newID int var newID int
l.UUID = uu.String() l.UUID = uu.String()
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil { if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID); err != nil {
c.log.Printf("error creating list: %v", err) c.log.Printf("error creating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err))) c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
@ -147,7 +147,7 @@ func (c *Core) CreateList(l models.List) (models.List, error) {
// UpdateList updates a given list. // UpdateList updates a given list.
func (c *Core) UpdateList(id int, l models.List) (models.List, error) { 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, pq.StringArray(normalizeTags(l.Tags)), l.Description) res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)), l.Description, l.WelcomeTemplateID)
if err != nil { if err != nil {
c.log.Printf("error updating list: %v", err) c.log.Printf("error updating list: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,

View file

@ -294,6 +294,8 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
hasOptin = num > 0 hasOptin = num > 0
} }
c.sendWelcomeMessage(out.UUID, map[int]bool{})
return out, hasOptin, nil return out, hasOptin, nil
} }
@ -352,6 +354,9 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
} }
} }
// keep track of lists have been sent welcome emails..
welcomesSent := c.getWelcomesSent(sub.UUID)
_, err := c.q.UpdateSubscriberWithLists.Exec(id, _, err := c.q.UpdateSubscriberWithLists.Exec(id,
sub.Email, sub.Email,
strings.TrimSpace(sub.Name), strings.TrimSpace(sub.Name),
@ -379,9 +384,69 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
hasOptin = num > 0 hasOptin = num > 0
} }
// Send welcome tx messages.
c.sendWelcomeMessage(sub.UUID, welcomesSent)
return out, hasOptin, nil return out, hasOptin, nil
} }
func (c *Core) getWelcomesSent(subUUID string) map[int]bool {
welcomesSent := map[int]bool{}
if listSubs, err := c.GetSubscriptions(0, subUUID, false); err == nil {
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) {
listSubs, err := c.GetSubscriptions(0, subUUID, false)
if err != nil {
c.log.Printf("error getting the subscriber's lists: %v", err)
}
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]interface{}{}
if len(listSub.Meta) > 0 {
err := json.Unmarshal(listSub.Meta, &data)
if err != nil {
c.log.Printf("error unmarshalling sub meta: %v", err)
}
}
sub, err := c.GetSubscriber(0, subUUID, "")
if err != nil {
c.log.Printf("error sending welcome messages: subscriber not found %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)
}
}
}
// BlocklistSubscribers blocklists the given list of subscribers. // BlocklistSubscribers blocklists the given list of subscribers.
func (c *Core) BlocklistSubscribers(subIDs []int) error { func (c *Core) BlocklistSubscribers(subIDs []int) error {
if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil { if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
@ -451,12 +516,15 @@ func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, met
meta = models.JSON{} meta = models.JSON{}
} }
welcomesSent := c.getWelcomesSent(subUUID)
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil { if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
c.log.Printf("error confirming subscription: %v", err) c.log.Printf("error confirming subscription: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} }
c.sendWelcomeMessage(subUUID, welcomesSent)
return nil return nil
} }

View file

@ -0,0 +1,22 @@
package migrations
import (
"log"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V3_1_0 performs the DB migrations.
func V3_1_0(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
}

View file

@ -222,6 +222,8 @@ type List struct {
SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"` SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"`
SubscriberID int `db:"subscriber_id" json:"-"` 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. // This is only relevant when querying the lists of a subscriber.
SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"` SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"`
SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"` SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"`

View file

@ -446,7 +446,7 @@ SELECT * FROM lists WHERE (CASE WHEN $1 != '' THEN optin=$1::list_optin ELSE TRU
END) ORDER BY name; END) ORDER BY name;
-- name: create-list -- name: create-list
INSERT INTO lists (uuid, name, type, optin, tags, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING id; INSERT INTO lists (uuid, name, type, optin, tags, description, welcome_template_id) VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING id;
-- name: update-list -- name: update-list
UPDATE lists SET UPDATE lists SET
@ -455,6 +455,7 @@ UPDATE lists SET
optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END), optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END),
tags=$5::VARCHAR(100)[], tags=$5::VARCHAR(100)[],
description=(CASE WHEN $6 != '' THEN $6 ELSE description END), description=(CASE WHEN $6 != '' THEN $6 ELSE description END),
welcome_template_id=$7,
updated_at=NOW() updated_at=NOW()
WHERE id = $1; WHERE id = $1;

View file

@ -26,6 +26,21 @@ DROP INDEX IF EXISTS idx_subs_status; CREATE INDEX idx_subs_status ON subscriber
DROP INDEX IF EXISTS idx_subs_created_at; CREATE INDEX idx_subs_created_at ON subscribers(created_at); 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); 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,
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 -- lists
DROP TABLE IF EXISTS lists CASCADE; DROP TABLE IF EXISTS lists CASCADE;
CREATE TABLE lists ( CREATE TABLE lists (
@ -37,6 +52,8 @@ CREATE TABLE lists (
tags VARCHAR(100)[], tags VARCHAR(100)[],
description TEXT NOT NULL DEFAULT '', 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(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@ -63,22 +80,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_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); 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,
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 -- campaigns
DROP TABLE IF EXISTS campaigns CASCADE; DROP TABLE IF EXISTS campaigns CASCADE;
CREATE TABLE campaigns ( CREATE TABLE campaigns (