mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
Compare commits
3 commits
c2edd41da4
...
c7880d6fe8
Author | SHA1 | Date | |
---|---|---|---|
c7880d6fe8 | |||
550cd3e1f8 | |||
5d89b38138 |
|
@ -48,7 +48,7 @@ __________________
|
|||
|
||||
|
||||
## 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
|
||||
|
|
|
@ -74,6 +74,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
models.ListOptinSingle,
|
||||
pq.StringArray{"test"},
|
||||
"",
|
||||
nil,
|
||||
); err != nil {
|
||||
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,
|
||||
pq.StringArray{"test"},
|
||||
"",
|
||||
nil,
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating list: %v", err)
|
||||
}
|
||||
|
|
|
@ -205,6 +205,7 @@ func main() {
|
|||
|
||||
app.core = core.New(cOpt, &core.Hooks{
|
||||
SendOptinConfirmation: sendOptinConfirmationHook(app),
|
||||
SendTxMessage: sendTxMessageHook(app),
|
||||
})
|
||||
|
||||
app.queries = queries
|
||||
|
|
|
@ -631,3 +631,11 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
12
cmd/tx.go
12
cmd/tx.go
|
@ -66,6 +66,15 @@ func handleSendTxMessage(c echo.Context) error {
|
|||
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.
|
||||
if r, err := validateTxMessage(m, app); err != nil {
|
||||
return err
|
||||
|
@ -156,8 +165,7 @@ func handleSendTxMessage(c echo.Context) error {
|
|||
if len(notFound) > 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {
|
||||
|
|
|
@ -38,6 +38,7 @@ var migList = []migFunc{
|
|||
{"v2.4.0", migrations.V2_4_0},
|
||||
{"v2.5.0", migrations.V2_5_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
|
||||
|
|
|
@ -235,3 +235,34 @@ 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;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,17 @@
|
|||
</b-select>
|
||||
</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-taginput v-model="form.tags" name="tags" ellipsis icon="tag-outline"
|
||||
:placeholder="$t('globals.terms.tags')" />
|
||||
|
@ -70,6 +81,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',
|
||||
|
@ -91,6 +103,7 @@ export default Vue.extend({
|
|||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: [],
|
||||
welcomeTemplateId: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -106,7 +119,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 }));
|
||||
|
@ -114,7 +127,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 }));
|
||||
|
@ -123,12 +137,15 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['loading']),
|
||||
...mapState(['loading', 'templates']),
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.form = { ...this.form, ...this.$props.data };
|
||||
|
||||
// Get the templates list.
|
||||
this.$api.getTemplates();
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.focus.focus();
|
||||
});
|
||||
|
|
|
@ -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.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",
|
||||
|
|
|
@ -50,6 +50,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(tx models.TxMessage) error
|
||||
}
|
||||
|
||||
// Opt contains the controllers required to start the core.
|
||||
|
|
|
@ -136,7 +136,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, 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)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
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.
|
||||
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 {
|
||||
c.log.Printf("error updating list: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
|
|
|
@ -294,6 +294,8 @@ func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs
|
|||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
c.sendWelcomeMessage(out.UUID, map[int]bool{})
|
||||
|
||||
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,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
|
@ -379,9 +384,69 @@ func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs
|
|||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
// Send welcome tx messages.
|
||||
c.sendWelcomeMessage(sub.UUID, welcomesSent)
|
||||
|
||||
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.
|
||||
func (c *Core) BlocklistSubscribers(subIDs []int) error {
|
||||
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{}
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
c.sendWelcomeMessage(subUUID, welcomesSent)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
22
internal/migrations/v3.1.0.go
Normal file
22
internal/migrations/v3.1.0.go
Normal 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
|
||||
}
|
|
@ -222,6 +222,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"`
|
||||
|
|
|
@ -446,7 +446,7 @@ SELECT * FROM lists WHERE (CASE WHEN $1 != '' THEN optin=$1::list_optin ELSE TRU
|
|||
END) ORDER BY name;
|
||||
|
||||
-- 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
|
||||
UPDATE lists SET
|
||||
|
@ -455,6 +455,7 @@ UPDATE lists SET
|
|||
optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END),
|
||||
tags=$5::VARCHAR(100)[],
|
||||
description=(CASE WHEN $6 != '' THEN $6 ELSE description END),
|
||||
welcome_template_id=$7,
|
||||
updated_at=NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
|
|
33
schema.sql
33
schema.sql
|
@ -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_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
|
||||
DROP TABLE IF EXISTS lists CASCADE;
|
||||
CREATE TABLE lists (
|
||||
|
@ -37,6 +52,8 @@ CREATE TABLE lists (
|
|||
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()
|
||||
);
|
||||
|
@ -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_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
|
||||
DROP TABLE IF EXISTS campaigns CASCADE;
|
||||
CREATE TABLE campaigns (
|
||||
|
|
Loading…
Reference in a new issue