Skip to content

feat: support configuring a welcome email on lists. #1818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
Expand Down
2 changes: 2 additions & 0 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ func main() {

app.core = core.New(cOpt, &core.Hooks{
SendOptinConfirmation: sendOptinConfirmationHook(app),
SendTxMessage: sendTxMessageHook(app),
})

app.queries = queries
Expand Down
8 changes: 8 additions & 0 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,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 changes: 10 additions & 2 deletions cmd/tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
23 changes: 20 additions & 3 deletions frontend/src/views/ListForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')" />
Expand All @@ -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',
Expand All @@ -91,6 +103,7 @@ export default Vue.extend({
type: 'private',
optin: 'single',
tags: [],
welcomeTemplateId: null,
},
};
},
Expand All @@ -106,15 +119,16 @@ 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 }));
});
},

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 }));
Expand All @@ -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();
});
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions internal/core/lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions internal/core/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
22 changes: 22 additions & 0 deletions internal/migrations/v3.1.0.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
3 changes: 2 additions & 1 deletion queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down
Loading