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:
Kailash Nadh 2022-07-02 15:30:17 +05:30
parent 13603b7141
commit 463e92d1e1
36 changed files with 600 additions and 67 deletions

View file

@ -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)

View file

@ -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(

View file

@ -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"))
}

View file

@ -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)

View file

@ -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
View 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
}

View file

@ -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

View file

@ -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;

View file

@ -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>

View file

@ -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,
};

View file

@ -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');

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "ログアウト"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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)))

View file

@ -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
}

View 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
}

View file

@ -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.

View file

@ -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;

View file

@ -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,

View 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;">&nbsp;</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>