Просмотр исходного кода

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.
Kailash Nadh 3 лет назад
Родитель
Сommit
463e92d1e1

+ 2 - 0
cmd/handlers.go

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

+ 17 - 10
cmd/install.go

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

+ 1 - 0
cmd/main.go

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

+ 55 - 8
cmd/templates.go

@@ -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())
+	}
+
+	// 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 = ""
 	}
 
-	out, err := app.core.CreateTemplate(o.Name, []byte(o.Body))
+	// 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 - 0
cmd/tx.go

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

+ 1 - 0
cmd/upgrade.go

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

+ 7 - 2
frontend/src/assets/style.scss

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

+ 4 - 1
frontend/src/views/Campaign.vue

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

+ 43 - 16
frontend/src/views/TemplateForm.vue

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

+ 20 - 4
frontend/src/views/Templates.vue

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

+ 2 - 0
i18n/cs-cz.json

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

+ 2 - 0
i18n/de.json

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

+ 2 - 0
i18n/en.json

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

+ 2 - 0
i18n/es.json

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

+ 2 - 0
i18n/fi.json

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

+ 2 - 0
i18n/fr.json

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

+ 2 - 0
i18n/hu.json

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

+ 2 - 0
i18n/it.json

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

+ 2 - 0
i18n/jp.json

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

+ 2 - 0
i18n/ml.json

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

+ 2 - 0
i18n/nl.json

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

+ 2 - 0
i18n/pl.json

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

+ 2 - 0
i18n/pt-BR.json

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

+ 2 - 0
i18n/pt.json

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

+ 2 - 0
i18n/ro.json

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

+ 2 - 0
i18n/ru.json

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

+ 2 - 0
i18n/tr.json

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

+ 2 - 0
i18n/vi.json

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

+ 7 - 7
internal/core/templates.go

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

+ 66 - 15
internal/manager/manager.go

@@ -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 - 0
internal/migrations/v2.2.0.go

@@ -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 - 1
models/models.go

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

+ 7 - 5
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;
 

+ 3 - 0
schema.sql

@@ -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 - 0
static/email-templates/sample-tx.tpl

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