Skip to content
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;
}
3 changes: 2 additions & 1 deletion frontend/src/views/CampaignAnalytics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ export default Vue.extend({
created() {
const now = dayjs().set('hour', 23).set('minute', 59).set('seconds', 0);
this.form.to = now.toDate();
this.form.from = now.subtract(7, 'day').set('hour', 0).set('minute', 0).toDate();
// this.form.from = now.subtract(7, 'day').set('hour', 0).set('minute', 0).toDate();
this.form.from = now.subtract(10, 'year').set('hour', 0).set('minute', 0).toDate();
},

mounted() {
Expand Down
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ require (
github.com/paulbellamy/ratecounter v0.2.0
github.com/rhnvrm/simples3 v0.8.3
github.com/spf13/pflag v1.0.5
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4
github.com/yuin/goldmark v1.6.0
github.com/zerodha/easyjson v1.0.0
golang.org/x/mod v0.14.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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
}
5 changes: 5 additions & 0 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/yosssi/gohtml"
"html/template"
"net/textproto"
"regexp"
Expand Down Expand Up @@ -222,6 +223,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 Expand Up @@ -567,6 +570,8 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
body = c.Body
}

body = gohtml.Format(body)

// Compile the campaign message.
for _, r := range regTplFuncs {
body = r.regExp.ReplaceAllString(body, r.replace)
Expand Down
Loading