Add transactional (tx) messaging capability.
This commit adds a new API `POST /api/tx` that sends an ad-hoc message to a subscriber based on a pre-defined transactional template. This is a large commit that adds the following: - New campaign / tx template types on the UI. tx templates have an additional subject field. - New fields `type` and `subject` to the templates table. - Refactor template CRUD operations and models. - Refactor template func assignment in manager. - Add pre-compiled template caching to manager runtime. - Pre-compile all tx templates into memory on program boot to avoid expensive template compilation on ad-hoc tx messages.
This commit is contained in:
parent
13603b7141
commit
463e92d1e1
36 changed files with 600 additions and 67 deletions
|
@ -138,6 +138,8 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
||||
g.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||
|
||||
g.POST("/api/tx", handleSendTxMessage)
|
||||
|
||||
if app.constants.BounceWebhooksEnabled {
|
||||
// Private authenticated bounce endpoint.
|
||||
g.POST("/webhooks/bounce", handleBounceWebhook)
|
||||
|
|
14
cmd/init.go
14
cmd/init.go
|
@ -426,6 +426,20 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma
|
|||
}, newManagerStore(q), campNotifCB, app.i18n, lo)
|
||||
}
|
||||
|
||||
func initTxTemplates(m *manager.Manager, app *App) {
|
||||
tpls, err := app.core.GetTemplates(models.TemplateTypeTx, false)
|
||||
if err != nil {
|
||||
lo.Fatalf("error loading transactional templates: %v", err)
|
||||
}
|
||||
|
||||
for _, t := range tpls {
|
||||
if err := t.Compile(app.manager.GenericTemplateFuncs()); err != nil {
|
||||
lo.Fatalf("error compiling transactional template %d: %v", t.ID, err)
|
||||
}
|
||||
m.CacheTpl(t.ID, &t)
|
||||
}
|
||||
}
|
||||
|
||||
// initImporter initializes the bulk subscriber importer.
|
||||
func initImporter(q *models.Queries, db *sqlx.DB, app *App) *subimporter.Importer {
|
||||
return subimporter.New(
|
||||
|
|
|
@ -109,20 +109,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error creating subscriber: %v", err)
|
||||
}
|
||||
|
||||
// Default template.
|
||||
tplBody, err := fs.Get("/static/email-templates/default.tpl")
|
||||
// Default campaign template.
|
||||
campTpl, err := fs.Get("/static/email-templates/default.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
var tplID int
|
||||
if err := q.CreateTemplate.Get(&tplID,
|
||||
"Default template",
|
||||
string(tplBody.ReadBytes()),
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating default template: %v", err)
|
||||
var campTplID int
|
||||
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating default campaign template: %v", err)
|
||||
}
|
||||
if _, err := q.SetDefaultTemplate.Exec(tplID); err != nil {
|
||||
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
|
||||
lo.Fatalf("error setting default template: %v", err)
|
||||
}
|
||||
|
||||
|
@ -146,12 +143,22 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
json.RawMessage("[]"),
|
||||
pq.StringArray{"test-campaign"},
|
||||
emailMsgr,
|
||||
1,
|
||||
campTplID,
|
||||
pq.Int64Array{1},
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating sample campaign: %v", err)
|
||||
}
|
||||
|
||||
// Sample tx template.
|
||||
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default e-mail template: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating sample transactional template: %v", err)
|
||||
}
|
||||
|
||||
lo.Printf("setup complete")
|
||||
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
|
||||
}
|
||||
|
|
|
@ -189,6 +189,7 @@ func main() {
|
|||
app.manager = initCampaignManager(app.queries, app.constants, app)
|
||||
app.importer = initImporter(app.queries, db, app)
|
||||
app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
|
||||
initTxTemplates(app.manager, app)
|
||||
|
||||
if ko.Bool("bounce.enabled") {
|
||||
app.bounce = initBounceManager(app)
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -48,7 +49,7 @@ func handleGetTemplates(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
out, err := app.core.GetTemplates(noBody)
|
||||
out, err := app.core.GetTemplates("", noBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -63,10 +64,15 @@ func handlePreviewTemplate(c echo.Context) error {
|
|||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
body = c.FormValue("body")
|
||||
typ = c.FormValue("typ")
|
||||
)
|
||||
|
||||
if typ == "" {
|
||||
typ = models.TemplateTypeCampaign
|
||||
}
|
||||
|
||||
if body != "" {
|
||||
if !regexpTplTag.MatchString(body) {
|
||||
if typ == models.TemplateTypeCampaign && !regexpTplTag.MatchString(body) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
|
||||
}
|
||||
|
@ -120,16 +126,33 @@ func handleCreateTemplate(c echo.Context) error {
|
|||
}
|
||||
|
||||
if err := validateTemplate(o, app); err != nil {
|
||||
return err
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
out, err := app.core.CreateTemplate(o.Name, []byte(o.Body))
|
||||
// Subject is only relevant for fixed tx templates. For campaigns,
|
||||
// the subject changes per campaign and is on models.Campaign.
|
||||
if o.Type == models.TemplateTypeCampaign {
|
||||
o.Subject = ""
|
||||
}
|
||||
|
||||
// Compile the template and validate.
|
||||
if err := o.Compile(app.manager.GenericTemplateFuncs()); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// Create the template the in the DB.
|
||||
out, err := app.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
// If it's a transactional template, cache it in the manager
|
||||
// to be used for arbitrary incoming tx message pushes.
|
||||
if o.Type == models.TemplateTypeTx {
|
||||
app.manager.CacheTpl(out.ID, &o)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleUpdateTemplate handles template modification.
|
||||
|
@ -152,11 +175,27 @@ func handleUpdateTemplate(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateTemplate(id, o.Name, []byte(o.Body))
|
||||
// Subject is only relevant for fixed tx templates. For campaigns,
|
||||
// the subject changes per campaign and is on models.Campaign.
|
||||
if o.Type == models.TemplateTypeCampaign {
|
||||
o.Subject = ""
|
||||
}
|
||||
|
||||
// Compile the template and validate.
|
||||
if err := o.Compile(app.manager.GenericTemplateFuncs()); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateTemplate(id, o.Name, o.Type, o.Subject, []byte(o.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's a transactional template, cache it.
|
||||
if o.Type == models.TemplateTypeTx {
|
||||
app.manager.CacheTpl(out.ID, &o)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
|
||||
}
|
||||
|
@ -194,19 +233,27 @@ func handleDeleteTemplate(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Delete cached template.
|
||||
app.manager.DeleteTpl(id)
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// validateTemplate validates template fields.
|
||||
// compileTemplate validates template fields.
|
||||
func validateTemplate(o models.Template, app *App) error {
|
||||
if !strHasLen(o.Name, 1, stdInputMaxLen) {
|
||||
return errors.New(app.i18n.T("campaigns.fieldInvalidName"))
|
||||
}
|
||||
|
||||
if !regexpTplTag.MatchString(o.Body) {
|
||||
if o.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(o.Body) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
|
||||
}
|
||||
|
||||
if o.Type == models.TemplateTypeTx && strings.TrimSpace(o.Subject) == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.missingFields", "name", "subject"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
100
cmd/tx.go
Normal file
100
cmd/tx.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleSendTxMessage handles the sending of a transactional message.
|
||||
func handleSendTxMessage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
m models.TxMessage
|
||||
)
|
||||
|
||||
if err := c.Bind(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate input.
|
||||
if r, err := validateTxMessage(m, app); err != nil {
|
||||
return err
|
||||
} else {
|
||||
m = r
|
||||
}
|
||||
|
||||
// Get the cached tx template.
|
||||
tpl, err := app.manager.GetTpl(m.TemplateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the subscriber.
|
||||
sub, err := app.core.GetSubscriber(m.SubscriberID, "", m.SubscriberEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Render the message.
|
||||
if err := m.Render(sub, tpl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prepare the final message.
|
||||
msg := manager.Message{}
|
||||
msg.Subscriber = sub
|
||||
msg.To = []string{sub.Email}
|
||||
msg.From = m.FromEmail
|
||||
msg.Subject = m.Subject
|
||||
msg.ContentType = m.ContentType
|
||||
msg.Messenger = m.Messenger
|
||||
msg.Body = m.Body
|
||||
|
||||
// Optional headers.
|
||||
if len(m.Headers) != 0 {
|
||||
msg.Headers = make(textproto.MIMEHeader)
|
||||
for _, set := range msg.Campaign.Headers {
|
||||
for hdr, val := range set {
|
||||
msg.Headers.Add(hdr, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.manager.PushMessage(msg); err != nil {
|
||||
app.log.Printf("error sending message (%s): %v", msg.Subject, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {
|
||||
if m.SubscriberEmail == "" && m.SubscriberID == 0 {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.missingFields", "name", "subscriber_email or subscriber_id"))
|
||||
}
|
||||
|
||||
if m.SubscriberEmail != "" {
|
||||
em, err := app.importer.SanitizeEmail(m.SubscriberEmail)
|
||||
if err != nil {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
m.SubscriberEmail = em
|
||||
}
|
||||
|
||||
if m.FromEmail == "" {
|
||||
m.FromEmail = app.constants.FromEmail
|
||||
}
|
||||
|
||||
if m.Messenger == "" {
|
||||
m.Messenger = emailMsgr
|
||||
} else if !app.manager.HasMessenger(m.Messenger) {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger))
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
|
@ -32,6 +32,7 @@ var migList = []migFunc{
|
|||
{"v1.0.0", migrations.V1_0_0},
|
||||
{"v2.0.0", migrations.V2_0_0},
|
||||
{"v2.1.0", migrations.V2_1_0},
|
||||
{"v2.2.0", migrations.V2_2_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
|
|
@ -521,14 +521,14 @@ body.is-noscroll {
|
|||
color: $grey;
|
||||
}
|
||||
|
||||
&.private, &.scheduled, &.paused {
|
||||
&.private, &.scheduled, &.paused, &.tx {
|
||||
$color: #ed7b00;
|
||||
color: $color;
|
||||
background: #fff7e6;
|
||||
border: 1px solid lighten($color, 37%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 37%);
|
||||
}
|
||||
&.public, &.running, &.list {
|
||||
&.public, &.running, &.list, &.campaign {
|
||||
$color: $primary;
|
||||
color: lighten($color, 20%);;
|
||||
background: #e6f7ff;
|
||||
|
@ -800,6 +800,11 @@ section.analytics {
|
|||
}
|
||||
|
||||
/* Template form */
|
||||
.templates {
|
||||
td .tag {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
.template-modal {
|
||||
.template-modal-content {
|
||||
height: 95vh;
|
||||
|
|
|
@ -84,7 +84,10 @@
|
|||
<b-field :label="$tc('globals.terms.template')" label-position="on-border">
|
||||
<b-select :placeholder="$tc('globals.terms.template')" v-model="form.templateId"
|
||||
name="template" :disabled="!canEdit" required>
|
||||
<option v-for="t in templates" :value="t.id" :key="t.id">{{ t.name }}</option>
|
||||
<template v-for="t in templates">
|
||||
<option v-if="t.type === 'campaign'"
|
||||
:value="t.id" :key="t.id">{{ t.name }}</option>
|
||||
</template>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
|
|
|
@ -11,22 +11,44 @@
|
|||
<h4 v-else>{{ $t('templates.newTemplate') }}</h4>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
|
||||
:placeholder="$t('globals.fields.name')" required />
|
||||
</b-field>
|
||||
<div class="columns">
|
||||
<div class="column is-9">
|
||||
<b-field :label="$t('globals.fields.name')" label-position="on-border">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name"
|
||||
:placeholder="$t('globals.fields.name')" required />
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<b-field :label="$t('globals.fields.type')" label-position="on-border">
|
||||
<b-select v-model="form.type" expanded>
|
||||
<option value="campaign">{{ $tc('globals.terms.campaign') }}</option>
|
||||
<option value="tx">{{ $tc('globals.terms.tx') }}</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns" v-if="form.type === 'tx'">
|
||||
<div class="column is-12">
|
||||
<b-field :label="$t('templates.subject')" label-position="on-border">
|
||||
<b-input :maxlength="200" :ref="'focus'" v-model="form.subject" name="name"
|
||||
:placeholder="$t('templates.subject')" required />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-field v-if="form.body !== null"
|
||||
:label="$t('templates.rawHTML')" label-position="on-border">
|
||||
<html-editor v-model="form.body" name="body" />
|
||||
</b-field>
|
||||
<b-field v-if="form.body !== null"
|
||||
:label="$t('templates.rawHTML')" label-position="on-border">
|
||||
<html-editor v-model="form.body" name="body" />
|
||||
</b-field>
|
||||
|
||||
<p class="is-size-7">
|
||||
<p class="is-size-7">
|
||||
<template v-if="form.type === 'campaign'">
|
||||
{{ $t('templates.placeholderHelp', { placeholder: egPlaceholder }) }}
|
||||
<a target="_blank" href="https://listmonk.app/docs/templating">
|
||||
{{ $t('globals.buttons.learnMore') }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<a target="_blank" href="https://listmonk.app/docs/templating">
|
||||
{{ $t('globals.buttons.learnMore') }}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">{{ $t('globals.buttons.close') }}</b-button>
|
||||
|
@ -65,7 +87,8 @@ export default Vue.extend({
|
|||
// Binds form input values.
|
||||
form: {
|
||||
name: '',
|
||||
type: '',
|
||||
subject: '',
|
||||
type: 'campaign',
|
||||
optin: '',
|
||||
body: null,
|
||||
},
|
||||
|
@ -96,6 +119,8 @@ export default Vue.extend({
|
|||
const data = {
|
||||
id: this.data.id,
|
||||
name: this.form.name,
|
||||
type: this.form.type,
|
||||
subject: this.form.subject,
|
||||
body: this.form.body,
|
||||
};
|
||||
|
||||
|
@ -110,6 +135,8 @@ export default Vue.extend({
|
|||
const data = {
|
||||
id: this.data.id,
|
||||
name: this.form.name,
|
||||
type: this.form.type,
|
||||
subject: this.form.subject,
|
||||
body: this.form.body,
|
||||
};
|
||||
|
||||
|
|
|
@ -19,10 +19,26 @@
|
|||
default-sort="createdAt">
|
||||
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')"
|
||||
:td-attrs="$utils.tdID" sortable>
|
||||
<a :href="props.row.id" @click.prevent="showEditForm(props.row)">
|
||||
<a href="#" @click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
</a>
|
||||
<b-tag v-if="props.row.isDefault">{{ $t('templates.default') }}</b-tag>
|
||||
|
||||
<p class="is-size-7 has-text-grey" v-if="props.row.type === 'tx'">
|
||||
{{ props.row.subject }}
|
||||
</p>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="type"
|
||||
:label="$t('globals.fields.type')" sortable>
|
||||
<b-tag v-if="props.row.type === 'campaign'"
|
||||
:class="props.row.type" :data-cy="`type-${props.row.type}`">
|
||||
{{ $tc('globals.terms.campaign', 1) }}
|
||||
</b-tag>
|
||||
<b-tag v-else
|
||||
:class="props.row.type" :data-cy="`type-${props.row.type}`">
|
||||
{{ $tc('globals.terms.tx', 1) }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="createdAt"
|
||||
|
@ -55,7 +71,7 @@
|
|||
<b-icon icon="file-multiple-outline" size="is-small" />
|
||||
</b-tooltip>
|
||||
</a>
|
||||
<a v-if="!props.row.isDefault" href="#"
|
||||
<a v-if="!props.row.isDefault && props.row.type !== 'tx'" href="#"
|
||||
@click.prevent="$utils.confirm(null, () => makeTemplateDefault(props.row))"
|
||||
data-cy="btn-set-default">
|
||||
<b-tooltip :label="$t('templates.makeDefault')" type="is-dark">
|
||||
|
@ -132,7 +148,7 @@ export default Vue.extend({
|
|||
|
||||
// Show the new form.
|
||||
showNewForm() {
|
||||
this.curItem = {};
|
||||
this.curItem = { type: 'campaign' };
|
||||
this.isFormVisible = true;
|
||||
this.isEditing = false;
|
||||
},
|
||||
|
@ -150,7 +166,7 @@ export default Vue.extend({
|
|||
},
|
||||
|
||||
cloneTemplate(name, t) {
|
||||
const data = { name, body: t.body };
|
||||
const data = { name, body: t.body, type: t.type };
|
||||
this.$api.createTemplate(data).then((d) => {
|
||||
this.$api.getTemplates();
|
||||
this.$emit('finished');
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Značky",
|
||||
"globals.terms.template": "Šablona | Šablony",
|
||||
"globals.terms.templates": "Šablony",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Import již běží. Počkejte na jeho dokončení nebo jej zastavte před dalším pokusem.",
|
||||
"import.blocklist": "Seznam blokovaných",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Zástupný symbol {placeholder} by se měl v šabloně objevit právě jednou.",
|
||||
"templates.preview": "Náhled",
|
||||
"templates.rawHTML": "Kód HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tags",
|
||||
"globals.terms.template": "Vorlage | Vorlagen",
|
||||
"globals.terms.templates": "Vorlagen",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Jahr | Jahre",
|
||||
"import.alreadyRunning": "Bitte warte bis der aktuelle Importvorgang beendet wurde.",
|
||||
"import.blocklist": "Sperrliste",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Der Platzhalter \"{placeholder}\" darf nur einmal im Template vorkommen.",
|
||||
"templates.preview": "Vorschau",
|
||||
"templates.rawHTML": "HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Anmelden",
|
||||
"users.logout": "Abmelden"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tags",
|
||||
"globals.terms.template": "Template | Templates",
|
||||
"globals.terms.templates": "Templates",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "An import is already running. Wait for it to finish or stop it before trying again.",
|
||||
"import.blocklist": "Blocklist",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.",
|
||||
"templates.preview": "Preview",
|
||||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Etiqueta",
|
||||
"globals.terms.template": "Plantilla | Plantillas",
|
||||
"globals.terms.templates": "Plantillas",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Se está ejecutándo una importación. Espere a que termine o deténgala antes de intentar otra vez.",
|
||||
"import.blocklist": "Lista de bloqueados",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "El marcador {placeholder} debe aparecer exactamente una vez en la plantilla.",
|
||||
"templates.preview": "Vista pewliminar",
|
||||
"templates.rawHTML": "HTML crudo",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Entrar",
|
||||
"users.logout": "Salir"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tags",
|
||||
"globals.terms.template": "Template | Templates",
|
||||
"globals.terms.templates": "Templates",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "An import is already running. Wait for it to finish or stop it before trying again.",
|
||||
"import.blocklist": "Blocklist",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "The placeholder {placeholder} should appear exactly once in the template.",
|
||||
"templates.preview": "Preview",
|
||||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Étiquettes",
|
||||
"globals.terms.template": "Modèle | Modèles",
|
||||
"globals.terms.templates": "Modèles",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Année | Années",
|
||||
"import.alreadyRunning": "Une importation est déjà en cours. Attendez qu'elle se termine ou arrêtez-la avant de réessayer.",
|
||||
"import.blocklist": "Bloquer les adresses importées",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "L'espace réservé {placeholder} doit apparaître exactement une fois dans le modèle.",
|
||||
"templates.preview": "Aperçu",
|
||||
"templates.rawHTML": "HTML brut",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Connecter",
|
||||
"users.logout": "Déconnecter"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Címkék",
|
||||
"globals.terms.template": "Sablon | Sablonok",
|
||||
"globals.terms.templates": "Sablonok",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Már fut az importálás. Várja meg, amíg befejeződik, vagy állítsa le, mielőtt újra próbálkozna.",
|
||||
"import.blocklist": "Tiltólista",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "A {placeholder} helyőrzőnek pontosan egyszer kell megjelennie a sablonban.",
|
||||
"templates.preview": "Előnézet",
|
||||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Belépés",
|
||||
"users.logout": "Kijelentkezés"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Etichette",
|
||||
"globals.terms.template": "Modello | Modelli",
|
||||
"globals.terms.templates": "Modelli",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Un'importazione è già in corso. Aspetta che finisca o interrompila prima di riprovare.",
|
||||
"import.blocklist": "Lista degli indirizzi bloccati",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Il segnaposto {placeholder} deve apparire esattamente una volta nel modello.",
|
||||
"templates.preview": "Anteprima",
|
||||
"templates.rawHTML": "HTML semplice",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "タグ",
|
||||
"globals.terms.template": "テンプレート | テンプレート",
|
||||
"globals.terms.templates": "テンプレート",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "都市 | 都市",
|
||||
"import.alreadyRunning": "インポートはすでに実行されています。終わるまで待つか、停止してから再試行してください。",
|
||||
"import.blocklist": "ブロックリスト",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "プレースホルダー{プレースホルダー}はテンプレートに一度だけ表示される必要があります。",
|
||||
"templates.preview": "プレビュー",
|
||||
"templates.rawHTML": "Raw HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "ログイン",
|
||||
"users.logout": "ログアウト"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "ടാഗുകൾ",
|
||||
"globals.terms.template": "ടെംപ്ലേറ്റ് | ടെംപ്ലേറ്റുകൾ",
|
||||
"globals.terms.templates": "ടെംപ്ലേറ്റുകൾ",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "ഒരു ഇമ്പോർട്ട് ഇപ്പോൾ നടന്നുകൊണ്ടിരിക്കുന്നു. വീണ്ടും ശ്രമിക്കുന്നതിന് മുമ്പ് കാത്തിരിക്കുകയോ നടന്നുകൊണ്ടിരിക്കുന്ന ഇമ്പോർട്ട് നിർത്തുകയോ ചെയ്യുക.",
|
||||
"import.blocklist": "തടയുന്ന പട്ടിക",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "{placeholder} എന്ന പ്ലെയ്സ്ഹോൾഡർ ടെംപ്ലേറ്റിൽ ഒരിക്കലെങ്കിലും വരണം.",
|
||||
"templates.preview": "പ്രിവ്യൂ",
|
||||
"templates.rawHTML": "എച്. ടീ. എം. എൽ",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Labels",
|
||||
"globals.terms.template": "Sjabloon | Sjablonen",
|
||||
"globals.terms.templates": "Sjablonen",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Jaar | Jaren",
|
||||
"import.alreadyRunning": "Er is al een importeeractie bezig. Wacht tot deze gedaan is of annuleer voor het opnieuw te proberen.",
|
||||
"import.blocklist": "Geblokkeerd",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "De plaatshouder {placeholder} moet exact een keer voorkomen in de template.",
|
||||
"templates.preview": "Voorbeeld",
|
||||
"templates.rawHTML": "HTML code",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Inloggen",
|
||||
"users.logout": "Uitloggen"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tagi",
|
||||
"globals.terms.template": "Szablon | Szablony",
|
||||
"globals.terms.templates": "Szablony",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Importowanie jest już uruchomione. Poczekaj, aż się zakończy, albo zatrzymaj je przed ponowną próbą.",
|
||||
"import.blocklist": "Lista zablokowanych",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Symbol zastępczy {placeholder} powinien występować dokładnie raz w szablonie.",
|
||||
"templates.preview": "Podgląd",
|
||||
"templates.rawHTML": "Surowy HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Zaloguj",
|
||||
"users.logout": "Wyloguj"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tags",
|
||||
"globals.terms.template": "Modelo | Modelos",
|
||||
"globals.terms.templates": "Modelos",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Uma importação já está em execução. Aguarde até que termine ou pare-a antes de tentar novamente.",
|
||||
"import.blocklist": "Lista de bloqueio",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "O palavra reservada {placeholder} deve aparecer exatamente uma vez no modelo.",
|
||||
"templates.preview": "Pré-visualizar",
|
||||
"templates.rawHTML": "Código HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Etiquetas",
|
||||
"globals.terms.template": "Modelo | Modelos",
|
||||
"globals.terms.templates": "Modelo",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Uma importação já está em curso. Aguarda que termine ou cancela-a antes de tentares novamente.",
|
||||
"import.blocklist": "Lista de bloqueio",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "O placeholder {placeholder} deve aparecer exatamente uma vez no template.",
|
||||
"templates.preview": "Pré-visualização",
|
||||
"templates.rawHTML": "HTML Simples",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Etichete",
|
||||
"globals.terms.template": "Șablon | Șabloane",
|
||||
"globals.terms.templates": "Șabloane",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Un import rulează deja. Așteptă să se termine sau oprește-l înainte de a încerca din nou.",
|
||||
"import.blocklist": "Lista de blocați",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Substituentul {placeholder} ar trebui să apară exact o dată în șablon.",
|
||||
"templates.preview": "Previzualizare",
|
||||
"templates.rawHTML": "HTML brut",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Теги",
|
||||
"globals.terms.template": "Шаблон | Шаблоны",
|
||||
"globals.terms.templates": "Шаблоны",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Импорт уже выполняется. Подождите, пока он закончит, или остановите его, прежде чем пытаться снова. ",
|
||||
"import.blocklist": "Список блокировки",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Заполнитель {placeholder} должен присутствовать в шаблоне в одном экземпляре.",
|
||||
"templates.preview": "Предпросмотр",
|
||||
"templates.rawHTML": "Необработанный HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Tag(lar)",
|
||||
"globals.terms.template": "Taslak | Taslaklar",
|
||||
"globals.terms.templates": "Taslaklar",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Bir içe aktarım halen sürüyor. Yeniden denemek için durdurun veya yeniden denemek için bekleyin.",
|
||||
"import.blocklist": "Engelli listesi",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Yer tutucu {placeholder} taslak içinde sadece bir kere olmalıdır.",
|
||||
"templates.preview": "Önizleme",
|
||||
"templates.rawHTML": "Ham HTML",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Login",
|
||||
"users.logout": "Logout"
|
||||
}
|
||||
|
|
|
@ -204,6 +204,7 @@
|
|||
"globals.terms.tags": "Thẻ",
|
||||
"globals.terms.template": "Template | Templates",
|
||||
"globals.terms.templates": "Mẫu",
|
||||
"globals.terms.tx": "Transactional | Transactional",
|
||||
"globals.terms.year": "Year | Years",
|
||||
"import.alreadyRunning": "Quá trình nhập đang chạy. Chờ quá trình hoàn tất hoặc dừng trước khi thử lại.",
|
||||
"import.blocklist": "Danh sách chặn",
|
||||
|
@ -513,6 +514,7 @@
|
|||
"templates.placeholderHelp": "Trình giữ chỗ {placeholder} sẽ xuất hiện chính xác một lần trong mẫu.",
|
||||
"templates.preview": "Xem trước",
|
||||
"templates.rawHTML": "HTML thô",
|
||||
"templates.subject": "Subject",
|
||||
"users.login": "Đăng nhập",
|
||||
"users.logout": "Đăng xuất"
|
||||
}
|
||||
|
|
|
@ -8,9 +8,9 @@ import (
|
|||
)
|
||||
|
||||
// GetTemplates retrieves all templates.
|
||||
func (c *Core) GetTemplates(noBody bool) ([]models.Template, error) {
|
||||
func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, error) {
|
||||
out := []models.Template{}
|
||||
if err := c.q.GetTemplates.Select(&out, 0, noBody); err != nil {
|
||||
if err := c.q.GetTemplates.Select(&out, 0, noBody, status); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ func (c *Core) GetTemplates(noBody bool) ([]models.Template, error) {
|
|||
// GetTemplate retrieves a given template.
|
||||
func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
|
||||
var out []models.Template
|
||||
if err := c.q.GetTemplates.Select(&out, id, noBody); err != nil {
|
||||
if err := c.q.GetTemplates.Select(&out, id, noBody, ""); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
@ -35,9 +35,9 @@ func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
|
|||
}
|
||||
|
||||
// CreateTemplate creates a new template.
|
||||
func (c *Core) CreateTemplate(name string, body []byte) (models.Template, error) {
|
||||
func (c *Core) CreateTemplate(name, typ, subject string, body []byte) (models.Template, error) {
|
||||
var newID int
|
||||
if err := c.q.CreateTemplate.Get(&newID, name, body); err != nil {
|
||||
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
@ -46,8 +46,8 @@ func (c *Core) CreateTemplate(name string, body []byte) (models.Template, error)
|
|||
}
|
||||
|
||||
// UpdateTemplate updates a given template.
|
||||
func (c *Core) UpdateTemplate(id int, name string, body []byte) (models.Template, error) {
|
||||
res, err := c.q.UpdateTemplate.Exec(id, name, body)
|
||||
func (c *Core) UpdateTemplate(id int, name, typ, subject string, body []byte) (models.Template, error) {
|
||||
res, err := c.q.UpdateTemplate.Exec(id, name, typ, subject, body)
|
||||
if err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
|
|
|
@ -60,6 +60,9 @@ type Manager struct {
|
|||
campRates map[int]*ratecounter.RateCounter
|
||||
campsMut sync.RWMutex
|
||||
|
||||
tpls map[int]*models.Template
|
||||
tplsMut sync.RWMutex
|
||||
|
||||
// Links generated using Track() are cached here so as to not query
|
||||
// the database for the link UUID for every message sent. This has to
|
||||
// be locked as it may be used externally when previewing campaigns.
|
||||
|
@ -77,6 +80,8 @@ type Manager struct {
|
|||
// sending further messages.
|
||||
slidingWindowNumMsg int
|
||||
slidingWindowStart time.Time
|
||||
|
||||
tplFuncs template.FuncMap
|
||||
}
|
||||
|
||||
// CampaignMessage represents an instance of campaign message to be pushed out,
|
||||
|
@ -152,7 +157,7 @@ func New(cfg Config, store Store, notifCB models.AdminNotifCallback, i *i18n.I18
|
|||
cfg.MessageRate = 1
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
i18n: i,
|
||||
|
@ -161,6 +166,7 @@ func New(cfg Config, store Store, notifCB models.AdminNotifCallback, i *i18n.I18
|
|||
messengers: make(map[string]messenger.Messenger),
|
||||
camps: make(map[int]*models.Campaign),
|
||||
campRates: make(map[int]*ratecounter.RateCounter),
|
||||
tpls: make(map[int]*models.Template),
|
||||
links: make(map[string]string),
|
||||
subFetchQueue: make(chan *models.Campaign, cfg.Concurrency),
|
||||
campMsgQueue: make(chan CampaignMessage, cfg.Concurrency*2),
|
||||
|
@ -169,6 +175,9 @@ func New(cfg Config, store Store, notifCB models.AdminNotifCallback, i *i18n.I18
|
|||
campMsgErrorCounts: make(map[int]int),
|
||||
slidingWindowStart: time.Now(),
|
||||
}
|
||||
m.tplFuncs = m.makeGnericFuncMap()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewCampaignMessage creates and returns a CampaignMessage that is made available
|
||||
|
@ -217,7 +226,7 @@ func (m *Manager) PushMessage(msg Message) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// PushCampaignMessage pushes a campaign messages to be sent out by the workers.
|
||||
// PushCampaignMessage pushes a campaign messages into a queue to be sent out by the workers.
|
||||
// It times out if the queue is busy.
|
||||
func (m *Manager) PushCampaignMessage(msg CampaignMessage) error {
|
||||
t := time.NewTicker(pushTimeout)
|
||||
|
@ -298,6 +307,33 @@ func (m *Manager) Run() {
|
|||
}
|
||||
}
|
||||
|
||||
// CacheTpl caches a template for ad-hoc use. This is currently only used by tx templates.
|
||||
func (m *Manager) CacheTpl(id int, tpl *models.Template) {
|
||||
m.tplsMut.Lock()
|
||||
m.tpls[id] = tpl
|
||||
m.tplsMut.Unlock()
|
||||
}
|
||||
|
||||
// DeleteTpl deletes a cached template.
|
||||
func (m *Manager) DeleteTpl(id int) {
|
||||
m.tplsMut.Lock()
|
||||
delete(m.tpls, id)
|
||||
m.tplsMut.Unlock()
|
||||
}
|
||||
|
||||
// GetTpl returns a cached template.
|
||||
func (m *Manager) GetTpl(id int) (*models.Template, error) {
|
||||
m.tplsMut.RLock()
|
||||
tpl, ok := m.tpls[id]
|
||||
m.tplsMut.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("template %d not found", id)
|
||||
}
|
||||
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// worker is a blocking function that perpetually listents to events (message) on different
|
||||
// queues and processes them.
|
||||
func (m *Manager) worker() {
|
||||
|
@ -423,27 +459,19 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
|||
"MessageURL": func(msg *CampaignMessage) string {
|
||||
return fmt.Sprintf(m.cfg.MessageURL, c.UUID, msg.Subscriber.UUID)
|
||||
},
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
}
|
||||
return time.Now().Format(layout)
|
||||
},
|
||||
"L": func() *i18n.I18n {
|
||||
return m.i18n
|
||||
},
|
||||
"Safe": func(safeHTML string) template.HTML {
|
||||
return template.HTML(safeHTML)
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range sprig.GenericFuncMap() {
|
||||
for k, v := range m.tplFuncs {
|
||||
f[k] = v
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *Manager) GenericTemplateFuncs() template.FuncMap {
|
||||
return m.tplFuncs
|
||||
}
|
||||
|
||||
// Close closes and exits the campaign manager.
|
||||
func (m *Manager) Close() {
|
||||
close(m.subFetchQueue)
|
||||
|
@ -751,3 +779,26 @@ func (m *CampaignMessage) AltBody() []byte {
|
|||
copy(out, m.altBody)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) makeGnericFuncMap() template.FuncMap {
|
||||
f := template.FuncMap{
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
}
|
||||
return time.Now().Format(layout)
|
||||
},
|
||||
"L": func() *i18n.I18n {
|
||||
return m.i18n
|
||||
},
|
||||
"Safe": func(safeHTML string) template.HTML {
|
||||
return template.HTML(safeHTML)
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range sprig.GenericFuncMap() {
|
||||
f[k] = v
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
|
31
internal/migrations/v2.2.0.go
Normal file
31
internal/migrations/v2.2.0.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
// V2_2_0 performs the DB migrations for v.2.2.0.
|
||||
func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
if _, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN
|
||||
CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
||||
END IF;
|
||||
END$$;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "subject" TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -80,6 +80,10 @@ const (
|
|||
|
||||
BounceTypeHard = "hard"
|
||||
BounceTypeSoft = "soft"
|
||||
|
||||
// Templates.
|
||||
TemplateTypeCampaign = "campaign"
|
||||
TemplateTypeTx = "tx"
|
||||
)
|
||||
|
||||
// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
|
||||
|
@ -294,9 +298,16 @@ type Campaigns []Campaign
|
|||
type Template struct {
|
||||
Base
|
||||
|
||||
Name string `db:"name" json:"name"`
|
||||
Name string `db:"name" json:"name"`
|
||||
// Subject is only for type=tx.
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Body string `db:"body" json:"body,omitempty"`
|
||||
IsDefault bool `db:"is_default" json:"is_default"`
|
||||
|
||||
// Only relevant to tx (transactional) templates.
|
||||
SubjectTpl *txttpl.Template `json:"-"`
|
||||
Tpl *template.Template `json:"-"`
|
||||
}
|
||||
|
||||
// Bounce represents a single bounce event.
|
||||
|
@ -320,6 +331,24 @@ type Bounce struct {
|
|||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// TxMessage represents an e-mail campaign.
|
||||
type TxMessage struct {
|
||||
SubscriberEmail string `json:"subscriber_email"`
|
||||
SubscriberID int `json:"subscriber_id"`
|
||||
|
||||
TemplateID int `json:"template_id"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
FromEmail string `json:"from_email"`
|
||||
Headers Headers `json:"headers"`
|
||||
ContentType string `json:"content_type"`
|
||||
Messenger string `json:"messenger"`
|
||||
|
||||
Subject string `json:"-"`
|
||||
Body []byte `json:"-"`
|
||||
Tpl *template.Template `json:"-"`
|
||||
SubjectTpl *txttpl.Template `json:"-"`
|
||||
}
|
||||
|
||||
// markdown is a global instance of Markdown parser and renderer.
|
||||
var markdown = goldmark.New(
|
||||
goldmark.WithParserOptions(
|
||||
|
@ -526,6 +555,56 @@ func (c *Campaign) ConvertContent(from, to string) (string, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// Compile compiles a template body and subject (only for tx templates) and
|
||||
// caches the templat references to be executed later.
|
||||
func (t *Template) Compile(f template.FuncMap) error {
|
||||
tpl, err := template.New(BaseTpl).Funcs(f).Parse(t.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling transactional template: %v", err)
|
||||
}
|
||||
t.Tpl = tpl
|
||||
|
||||
// If the subject line has a template string, compile it.
|
||||
if strings.Contains(t.Subject, "{{") {
|
||||
subj := t.Subject
|
||||
|
||||
subjTpl, err := txttpl.New(BaseTpl).Funcs(txttpl.FuncMap(f)).Parse(subj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling subject: %v", err)
|
||||
}
|
||||
t.SubjectTpl = subjTpl
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *TxMessage) Render(sub Subscriber, tpl *Template) error {
|
||||
data := struct {
|
||||
Subscriber Subscriber
|
||||
Tx *TxMessage
|
||||
}{sub, m}
|
||||
|
||||
// Render the body.
|
||||
b := bytes.Buffer{}
|
||||
if err := tpl.Tpl.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Body = make([]byte, b.Len())
|
||||
copy(m.Body, b.Bytes())
|
||||
b.Reset()
|
||||
|
||||
// If the subject is also a template, render that.
|
||||
if tpl.SubjectTpl != nil {
|
||||
if err := tpl.SubjectTpl.ExecuteTemplate(&b, BaseTpl, data); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Subject = b.String()
|
||||
b.Reset()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirstName splits the name by spaces and returns the first chunk
|
||||
// of the name that's greater than 2 characters in length, assuming
|
||||
// that it is the subscriber's first name.
|
||||
|
|
12
queries.sql
12
queries.sql
|
@ -746,24 +746,26 @@ DELETE FROM users WHERE $1 != 1 AND id=$1;
|
|||
-- templates
|
||||
-- name: get-templates
|
||||
-- Only if the second param ($2) is true, body is returned.
|
||||
SELECT id, name, (CASE WHEN $2 = false THEN body ELSE '' END) as body,
|
||||
SELECT id, name, type, subject, (CASE WHEN $2 = false THEN body ELSE '' END) as body,
|
||||
is_default, created_at, updated_at
|
||||
FROM templates WHERE $1 = 0 OR id = $1
|
||||
FROM templates WHERE ($1 = 0 OR id = $1) AND ($3 = '' OR type = $3::template_type)
|
||||
ORDER BY created_at;
|
||||
|
||||
-- name: create-template
|
||||
INSERT INTO templates (name, body) VALUES($1, $2) RETURNING id;
|
||||
INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4) RETURNING id;
|
||||
|
||||
-- name: update-template
|
||||
UPDATE templates SET
|
||||
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
||||
body=(CASE WHEN $3 != '' THEN $3 ELSE body END),
|
||||
type=(CASE WHEN $3 != '' THEN $3::template_type ELSE type END),
|
||||
subject=(CASE WHEN $4 != '' THEN $4 ELSE name END),
|
||||
body=(CASE WHEN $5 != '' THEN $5 ELSE body END),
|
||||
updated_at=NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: set-default-template
|
||||
WITH u AS (
|
||||
UPDATE templates SET is_default=true WHERE id=$1 RETURNING id
|
||||
UPDATE templates SET is_default=true WHERE id=$1 AND type='campaign' RETURNING id
|
||||
)
|
||||
UPDATE templates SET is_default=false WHERE id != $1;
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM
|
|||
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
|
||||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
||||
|
||||
-- subscribers
|
||||
DROP TABLE IF EXISTS subscribers CASCADE;
|
||||
|
@ -57,6 +58,8 @@ 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,
|
||||
|
||||
|
|
108
static/email-templates/sample-tx.tpl
Normal file
108
static/email-templates/sample-tx.tpl
Normal file
|
@ -0,0 +1,108 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||
<base target="_blank">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #F0F1F3;
|
||||
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
margin: 0;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4f4;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
table td {
|
||||
border-color: #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #0055d4;
|
||||
border-radius: 3px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: bold;
|
||||
padding: 10px 30px;
|
||||
display: inline-block;
|
||||
}
|
||||
.button:hover {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.footer a {
|
||||
color: #888;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0055d4;
|
||||
}
|
||||
a:hover {
|
||||
color: #111;
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.wrap {
|
||||
max-width: auto;
|
||||
}
|
||||
.gutter {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #F0F1F3;font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;font-size: 15px;line-height: 26px;margin: 0;color: #444;">
|
||||
<div class="gutter" style="padding: 30px;"> </div>
|
||||
<div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
|
||||
<p>Hello {{ .Subscriber.Name }}</p>
|
||||
<p>
|
||||
<strong>Order number: </strong> {{ .Tx.Data.order_id }}<br />
|
||||
<strong>Shipping date: </strong> {{ .Tx.Data.shipping_date }}<br />
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Transactional templates supports arbitrary parameters.
|
||||
Render them using <code>.Tx.Data.YourParamName</code>. For more information,
|
||||
see the transactional mailing <a href="https://listmonk.app/docs/transactional">documentation</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
|
||||
<p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue