Browse Source

Add support for per-campaign custom headers.

- Add new `headers[]` column to the campain table.
- Add new headers box to the campaign UI that takes a JSON array of
  custom headers like the headers on the SMTP settings UI.
- Headers are added to e-mails and messenger postback webhooks.
- Add cypress tests.

Closes #514.
Kailash Nadh 3 years ago
parent
commit
583dab4bc6

+ 7 - 0
cmd/campaigns.go

@@ -292,6 +292,7 @@ func handleCreateCampaign(c echo.Context) error {
 		o.AltBody,
 		o.AltBody,
 		o.ContentType,
 		o.ContentType,
 		o.SendAt,
 		o.SendAt,
+		o.Headers,
 		pq.StringArray(normalizeTags(o.Tags)),
 		pq.StringArray(normalizeTags(o.Tags)),
 		o.Messenger,
 		o.Messenger,
 		o.TemplateID,
 		o.TemplateID,
@@ -366,6 +367,7 @@ func handleUpdateCampaign(c echo.Context) error {
 		o.ContentType,
 		o.ContentType,
 		o.SendAt,
 		o.SendAt,
 		o.SendLater,
 		o.SendLater,
+		o.Headers,
 		pq.StringArray(normalizeTags(o.Tags)),
 		pq.StringArray(normalizeTags(o.Tags)),
 		o.Messenger,
 		o.Messenger,
 		o.TemplateID,
 		o.TemplateID,
@@ -603,6 +605,7 @@ func handleTestCampaign(c echo.Context) error {
 	camp.AltBody = req.AltBody
 	camp.AltBody = req.AltBody
 	camp.Messenger = req.Messenger
 	camp.Messenger = req.Messenger
 	camp.ContentType = req.ContentType
 	camp.ContentType = req.ContentType
+	camp.Headers = req.Headers
 	camp.TemplateID = req.TemplateID
 	camp.TemplateID = req.TemplateID
 
 
 	// Send the test messages.
 	// Send the test messages.
@@ -736,6 +739,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
 		return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
 		return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
 	}
 	}
 
 
+	if len(c.Headers) == 0 {
+		c.Headers = make([]map[string]string, 0)
+	}
+
 	return c, nil
 	return c, nil
 }
 }
 
 

+ 2 - 0
cmd/install.go

@@ -1,6 +1,7 @@
 package main
 package main
 
 
 import (
 import (
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"os"
 	"os"
@@ -146,6 +147,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
 		nil,
 		nil,
 		"richtext",
 		"richtext",
 		nil,
 		nil,
+		json.RawMessage("[]"),
 		pq.StringArray{"test-campaign"},
 		pq.StringArray{"test-campaign"},
 		emailMsgr,
 		emailMsgr,
 		1,
 		1,

+ 27 - 5
frontend/cypress/integration/campaigns.js

@@ -1,4 +1,5 @@
 const apiUrl = Cypress.env('apiUrl');
 const apiUrl = Cypress.env('apiUrl');
+const headers = '[{"X-Custom": "Custom-Value"}]';
 
 
 describe('Campaigns', () => {
 describe('Campaigns', () => {
   it('Opens campaigns page', () => {
   it('Opens campaigns page', () => {
@@ -38,6 +39,10 @@ describe('Campaigns', () => {
     cy.wait(100);
     cy.wait(100);
     cy.get('body').click(1, 1);
     cy.get('body').click(1, 1);
 
 
+    // Add custom headers.
+    cy.get('[data-cy=btn-headers]').click();
+    cy.get('textarea[name=headers]').invoke('val', headers).trigger('input');
+
     // Switch to content tab.
     // Switch to content tab.
     cy.get('.b-tabs nav a').eq(1).click();
     cy.get('.b-tabs nav a').eq(1).click();
 
 
@@ -70,6 +75,7 @@ describe('Campaigns', () => {
       expect(data.lists[0].id).to.equal(1);
       expect(data.lists[0].id).to.equal(1);
       expect(data.tags.length).to.equal(1);
       expect(data.tags.length).to.equal(1);
       expect(data.tags[0]).to.equal('new-tag');
       expect(data.tags[0]).to.equal('new-tag');
+      expect(data.headers[0]['X-Custom']).to.equal('Custom-Value');
     });
     });
 
 
     cy.get('tbody td[data-label=Status] .tag.scheduled');
     cy.get('tbody td[data-label=Status] .tag.scheduled');
@@ -181,18 +187,34 @@ describe('Campaigns', () => {
           cy.get('input[name=tags]').type(`tag${i}{enter}`);
           cy.get('input[name=tags]').type(`tag${i}{enter}`);
         }
         }
 
 
+        // Add headers.
+        cy.get('[data-cy=btn-headers]').click();
+        cy.get('textarea[name=headers]').invoke('val', `[{"X-Header-${n}": "Value-${n}"}]`).trigger('input');
+
         // Hit 'Continue'.
         // Hit 'Continue'.
         cy.get('button[data-cy=btn-continue]').click();
         cy.get('button[data-cy=btn-continue]').click();
         cy.wait(250);
         cy.wait(250);
 
 
+        // Verify the changes.
+        (function (n) {
+          cy.location('pathname').then((p) => {
+            cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => {
+              const { data } = response.body;
+              expect(data.status).to.equal('draft');
+              expect(data.name).to.equal(`name${n}`);
+              expect(data.subject).to.equal(`subject${n}`);
+              expect(data.content_type).to.equal('richtext');
+              expect(data.altbody).to.equal(null);
+              expect(data.send_at).to.equal(null);
+              expect(data.headers[0][`X-Header-${n}`]).to.equal(`Value-${n}`);
+            });
+          });
+        })(n);
+
+
         // Select content type.
         // Select content type.
         cy.get(`label[data-cy=check-${c}]`).click();
         cy.get(`label[data-cy=check-${c}]`).click();
 
 
-        // If it's not richtext, there's a "you'll lose formatting" prompt.
-        if (c !== 'richtext') {
-          cy.get('.modal button.is-primary').click();
-        }
-
         // Insert content.
         // Insert content.
         const htmlBody = `<strong>hello${n}</strong> \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`;
         const htmlBody = `<strong>hello${n}</strong> \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`;
         const plainBody = `hello${n} Demo Subscriber from Bengaluru`;
         const plainBody = `hello${n} Demo Subscriber from Bengaluru`;

+ 52 - 10
frontend/src/views/Campaign.vue

@@ -9,7 +9,7 @@
           <b-tag v-if="data.type === 'optin'" :class="data.type">
           <b-tag v-if="data.type === 'optin'" :class="data.type">
             {{ $t('lists.optin') }}
             {{ $t('lists.optin') }}
           </b-tag>
           </b-tag>
-          <span v-if="isEditing" class="has-text-grey-light is-size-7">
+          <span v-if="isEditing" class="has-text-grey-light is-size-7" :data-campaign-id="data.id">
             {{ $t('globals.fields.id') }}: {{ data.id }} /
             {{ $t('globals.fields.id') }}: {{ data.id }} /
             {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
             {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
           </span>
           </span>
@@ -22,7 +22,7 @@
         <div class="buttons">
         <div class="buttons">
           <b-field grouped v-if="isEditing && canEdit">
           <b-field grouped v-if="isEditing && canEdit">
             <b-field expanded>
             <b-field expanded>
-              <b-button  expanded @click="onSubmit" :loading="loading.campaigns"
+              <b-button  expanded @click="() => onSubmit('update')" :loading="loading.campaigns"
                 type="is-primary" icon-left="content-save-outline" data-cy="btn-save">
                 type="is-primary" icon-left="content-save-outline" data-cy="btn-save">
                 {{ $t('globals.buttons.saveChanges') }}
                 {{ $t('globals.buttons.saveChanges') }}
               </b-button>
               </b-button>
@@ -53,7 +53,7 @@
         <section class="wrap">
         <section class="wrap">
           <div class="columns">
           <div class="columns">
             <div class="column is-7">
             <div class="column is-7">
-              <form @submit.prevent="onSubmit">
+              <form @submit.prevent="() => onSubmit(isNew ? 'create' : 'update')">
                 <b-field :label="$t('globals.fields.name')" label-position="on-border">
                 <b-field :label="$t('globals.fields.name')" label-position="on-border">
                   <b-input :maxlength="200" :ref="'focus'" v-model="form.name"
                   <b-input :maxlength="200" :ref="'focus'" v-model="form.name"
                     name="name" :disabled="!canEdit"
                     name="name" :disabled="!canEdit"
@@ -124,6 +124,20 @@
                     </b-field>
                     </b-field>
                   </div>
                   </div>
                 </div>
                 </div>
+
+                <div>
+                  <p class="has-text-right">
+                    <a href="#" class="is-size-7" @click.prevent="showHeaders"
+                      data-cy="btn-headers">
+                      <b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}
+                    </a>
+                  </p>
+                  <b-field v-if="form.headersStr !== '[]' || isHeadersVisible"
+                    label-position="on-border" :message="$t('campaigns.customHeadersHelp')">
+                    <b-input v-model="form.headersStr" name="headers" type="textarea"
+                      placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />
+                  </b-field>
+                </div>
                 <hr />
                 <hr />
 
 
                 <b-field v-if="isNew">
                 <b-field v-if="isNew">
@@ -140,12 +154,12 @@
                 <h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
                 <h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
                   <b-field :message="$t('campaigns.sendTestHelp')">
                   <b-field :message="$t('campaigns.sendTestHelp')">
                     <b-taginput v-model="form.testEmails"
                     <b-taginput v-model="form.testEmails"
-                      :before-adding="$utils.validateEmail" :disabled="this.isNew"
+                      :before-adding="$utils.validateEmail" :disabled="isNew"
                       ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
                       ellipsis icon="email-outline" :placeholder="$t('campaigns.testEmails')" />
                   </b-field>
                   </b-field>
                   <b-field>
                   <b-field>
-                    <b-button @click="sendTest" :loading="loading.campaigns" :disabled="this.isNew"
-                      type="is-primary" icon-left="email-outline">
+                    <b-button @click="() => onSubmit('test')" :loading="loading.campaigns"
+                      :disabled="isNew" type="is-primary" icon-left="email-outline">
                       {{ $t('campaigns.send') }}
                       {{ $t('campaigns.send') }}
                     </b-button>
                     </b-button>
                   </b-field>
                   </b-field>
@@ -204,6 +218,7 @@ export default Vue.extend({
     return {
     return {
       isNew: false,
       isNew: false,
       isEditing: false,
       isEditing: false,
+      isHeadersVisible: false,
       activeTab: 0,
       activeTab: 0,
 
 
       data: {},
       data: {},
@@ -216,6 +231,8 @@ export default Vue.extend({
         name: '',
         name: '',
         subject: '',
         subject: '',
         fromEmail: '',
         fromEmail: '',
+        headersStr: '[]',
+        headers: [],
         templateId: 0,
         templateId: 0,
         lists: [],
         lists: [],
         tags: [],
         tags: [],
@@ -245,11 +262,32 @@ export default Vue.extend({
       this.form.altbody = null;
       this.form.altbody = null;
     },
     },
 
 
-    onSubmit() {
-      if (this.isNew) {
-        this.createCampaign();
+    showHeaders() {
+      this.isHeadersVisible = !this.isHeadersVisible;
+    },
+
+    onSubmit(typ) {
+      if (this.form.headersStr && this.form.headersStr !== '[]') {
+        try {
+          this.form.headers = JSON.parse(this.form.headersStr);
+        } catch (e) {
+          this.$utils.toast(e.toString(), 'is-danger');
+          return;
+        }
       } else {
       } else {
-        this.updateCampaign();
+        this.form.headers = [];
+      }
+
+      switch (typ) {
+        case 'create':
+          this.createCampaign();
+          break;
+        case 'test':
+          this.sendTest();
+          break;
+        default:
+          this.updateCampaign();
+          break;
       }
       }
     },
     },
 
 
@@ -259,6 +297,7 @@ export default Vue.extend({
         this.form = {
         this.form = {
           ...this.form,
           ...this.form,
           ...data,
           ...data,
+          headersStr: JSON.stringify(data.headers, null, 4),
 
 
           // The structure that is populated by editor input event.
           // The structure that is populated by editor input event.
           content: { contentType: data.contentType, body: data.body },
           content: { contentType: data.contentType, body: data.body },
@@ -280,6 +319,7 @@ export default Vue.extend({
         from_email: this.form.fromEmail,
         from_email: this.form.fromEmail,
         messenger: this.form.messenger,
         messenger: this.form.messenger,
         type: 'regular',
         type: 'regular',
+        headers: this.form.headers,
         tags: this.form.tags,
         tags: this.form.tags,
         template_id: this.form.templateId,
         template_id: this.form.templateId,
         content_type: this.form.content.contentType,
         content_type: this.form.content.contentType,
@@ -306,6 +346,7 @@ export default Vue.extend({
         tags: this.form.tags,
         tags: this.form.tags,
         send_later: this.form.sendLater,
         send_later: this.form.sendLater,
         send_at: this.form.sendLater ? this.form.sendAtDate : null,
         send_at: this.form.sendLater ? this.form.sendAtDate : null,
+        headers: this.form.headers,
         template_id: this.form.templateId,
         template_id: this.form.templateId,
         // body: this.form.body,
         // body: this.form.body,
       };
       };
@@ -327,6 +368,7 @@ export default Vue.extend({
         tags: this.form.tags,
         tags: this.form.tags,
         send_later: this.form.sendLater,
         send_later: this.form.sendLater,
         send_at: this.form.sendLater ? this.form.sendAtDate : null,
         send_at: this.form.sendLater ? this.form.sendAtDate : null,
+        headers: this.form.headers,
         template_id: this.form.templateId,
         template_id: this.form.templateId,
         content_type: this.form.content.contentType,
         content_type: this.form.content.contentType,
         body: this.form.content.body,
         body: this.form.content.body,

+ 1 - 1
frontend/src/views/settings/smtp.vue

@@ -142,7 +142,7 @@
                   <b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
                   <b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
               </p>
               </p>
               <b-field v-if="item.email_headers.length > 0 || item.showHeaders"
               <b-field v-if="item.email_headers.length > 0 || item.showHeaders"
-                :label="$t('')" label-position="on-border"
+                label-position="on-border"
                 :message="$t('settings.smtp.customHeadersHelp')">
                 :message="$t('settings.smtp.customHeadersHelp')">
                 <b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
                 <b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
                   placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />
                   placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />

+ 2 - 0
i18n/cs-cz.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Obsah zde",
     "campaigns.contentHelp": "Obsah zde",
     "campaigns.continue": "Pokračovat",
     "campaigns.continue": "Pokračovat",
     "campaigns.copyOf": "Kopie {name}",
     "campaigns.copyOf": "Kopie {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Datum a čas",
     "campaigns.dateAndTime": "Datum a čas",
     "campaigns.ended": "Ukončeno",
     "campaigns.ended": "Ukončeno",
     "campaigns.errorSendTest": "Chyba při odesílání testu: {error}",
     "campaigns.errorSendTest": "Chyba při odesílání testu: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Z adresy",
     "campaigns.fromAddress": "Z adresy",
     "campaigns.fromAddressPlaceholder": "Vaše jméno <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Vaše jméno <noreply@yoursite.com>",
     "campaigns.invalid": "Neplatná kampaň",
     "campaigns.invalid": "Neplatná kampaň",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Sleva",
     "campaigns.markdown": "Sleva",
     "campaigns.needsSendAt": "Kampaň musí mít naplánované datum.",
     "campaigns.needsSendAt": "Kampaň musí mít naplánované datum.",
     "campaigns.newCampaign": "Nová kampaň",
     "campaigns.newCampaign": "Nová kampaň",

+ 2 - 0
i18n/de.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Inhalt hier",
     "campaigns.contentHelp": "Inhalt hier",
     "campaigns.continue": "Fortsetzen",
     "campaigns.continue": "Fortsetzen",
     "campaigns.copyOf": "Kopie von {name}",
     "campaigns.copyOf": "Kopie von {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Datum und Zeit",
     "campaigns.dateAndTime": "Datum und Zeit",
     "campaigns.ended": "Abgeschlossen",
     "campaigns.ended": "Abgeschlossen",
     "campaigns.errorSendTest": "Fehler beim Senden der Testmail: {error}",
     "campaigns.errorSendTest": "Fehler beim Senden der Testmail: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Absender",
     "campaigns.fromAddress": "Absender",
     "campaigns.fromAddressPlaceholder": "Dein Name <noreply@deineseite.de>",
     "campaigns.fromAddressPlaceholder": "Dein Name <noreply@deineseite.de>",
     "campaigns.invalid": "Ungültige Kampagne",
     "campaigns.invalid": "Ungültige Kampagne",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Die Kampagne benötigt ein `send_at` Sendedatum, um automatisch verschickt zu werden.",
     "campaigns.needsSendAt": "Die Kampagne benötigt ein `send_at` Sendedatum, um automatisch verschickt zu werden.",
     "campaigns.newCampaign": "Neue Kampagne",
     "campaigns.newCampaign": "Neue Kampagne",

+ 2 - 0
i18n/en.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Content here",
     "campaigns.contentHelp": "Content here",
     "campaigns.continue": "Continue",
     "campaigns.continue": "Continue",
     "campaigns.copyOf": "Copy of {name}",
     "campaigns.copyOf": "Copy of {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Date and time",
     "campaigns.dateAndTime": "Date and time",
     "campaigns.ended": "Ended",
     "campaigns.ended": "Ended",
     "campaigns.errorSendTest": "Error sending test: {error}",
     "campaigns.errorSendTest": "Error sending test: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "From address",
     "campaigns.fromAddress": "From address",
     "campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
     "campaigns.invalid": "Invalid campaign",
     "campaigns.invalid": "Invalid campaign",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
     "campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
     "campaigns.newCampaign": "New campaign",
     "campaigns.newCampaign": "New campaign",

+ 2 - 0
i18n/es.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Contenido aqui",
     "campaigns.contentHelp": "Contenido aqui",
     "campaigns.continue": "Continuar",
     "campaigns.continue": "Continuar",
     "campaigns.copyOf": "Copia de {name}",
     "campaigns.copyOf": "Copia de {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Fecha y hora",
     "campaigns.dateAndTime": "Fecha y hora",
     "campaigns.ended": "Finalizado",
     "campaigns.ended": "Finalizado",
     "campaigns.errorSendTest": "Error al enviar la prueba: {error}",
     "campaigns.errorSendTest": "Error al enviar la prueba: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Dirección origen",
     "campaigns.fromAddress": "Dirección origen",
     "campaigns.fromAddressPlaceholder": "Su Nombre <noresponder@susitio.com>",
     "campaigns.fromAddressPlaceholder": "Su Nombre <noresponder@susitio.com>",
     "campaigns.invalid": "Campaña no válida",
     "campaigns.invalid": "Campaña no válida",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Una campaña necesita una fecha pra ser agendada.",
     "campaigns.needsSendAt": "Una campaña necesita una fecha pra ser agendada.",
     "campaigns.newCampaign": "Nueva campaña",
     "campaigns.newCampaign": "Nueva campaña",

+ 2 - 0
i18n/fr.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Rédigez le contenu ici.",
     "campaigns.contentHelp": "Rédigez le contenu ici.",
     "campaigns.continue": "Continuer",
     "campaigns.continue": "Continuer",
     "campaigns.copyOf": "Copie de {name}",
     "campaigns.copyOf": "Copie de {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Date et heure",
     "campaigns.dateAndTime": "Date et heure",
     "campaigns.ended": "Terminée",
     "campaigns.ended": "Terminée",
     "campaigns.errorSendTest": "Erreur lors de l'envoi du test : {error}",
     "campaigns.errorSendTest": "Erreur lors de l'envoi du test : {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Adresse d'envoi",
     "campaigns.fromAddress": "Adresse d'envoi",
     "campaigns.fromAddressPlaceholder": "Nom à afficher <noreply@votresite.com>",
     "campaigns.fromAddressPlaceholder": "Nom à afficher <noreply@votresite.com>",
     "campaigns.invalid": "Campagne non valide",
     "campaigns.invalid": "Campagne non valide",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Une date est nécessaire pour planifier la campagne.",
     "campaigns.needsSendAt": "Une date est nécessaire pour planifier la campagne.",
     "campaigns.newCampaign": "Nouvelle campagne",
     "campaigns.newCampaign": "Nouvelle campagne",

+ 2 - 0
i18n/hu.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Tartalom itt",
     "campaigns.contentHelp": "Tartalom itt",
     "campaigns.continue": "Folytatás",
     "campaigns.continue": "Folytatás",
     "campaigns.copyOf": "Másolata a {name}",
     "campaigns.copyOf": "Másolata a {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Dátum és Idő",
     "campaigns.dateAndTime": "Dátum és Idő",
     "campaigns.ended": "Befejezett",
     "campaigns.ended": "Befejezett",
     "campaigns.errorSendTest": "Hiba a teszt küldésekor: {error}",
     "campaigns.errorSendTest": "Hiba a teszt küldésekor: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Címről",
     "campaigns.fromAddress": "Címről",
     "campaigns.fromAddressPlaceholder": "A neved <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "A neved <noreply@yoursite.com>",
     "campaigns.invalid": "Érvénytelen kampány",
     "campaigns.invalid": "Érvénytelen kampány",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Csökkentés",
     "campaigns.markdown": "Csökkentés",
     "campaigns.needsSendAt": "A kampányhoz dátumot kell beállítani.",
     "campaigns.needsSendAt": "A kampányhoz dátumot kell beállítani.",
     "campaigns.newCampaign": "Új kampány",
     "campaigns.newCampaign": "Új kampány",

+ 2 - 0
i18n/it.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Contenuto qui",
     "campaigns.contentHelp": "Contenuto qui",
     "campaigns.continue": "Continuare",
     "campaigns.continue": "Continuare",
     "campaigns.copyOf": "Copie di {name}",
     "campaigns.copyOf": "Copie di {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Data e ora",
     "campaigns.dateAndTime": "Data e ora",
     "campaigns.ended": "Finito",
     "campaigns.ended": "Finito",
     "campaigns.errorSendTest": "Errore durante il test di invio: {error}",
     "campaigns.errorSendTest": "Errore durante il test di invio: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Mittente",
     "campaigns.fromAddress": "Mittente",
     "campaigns.fromAddressPlaceholder": "Tuo nome <noreply@tuosito.com>",
     "campaigns.fromAddressPlaceholder": "Tuo nome <noreply@tuosito.com>",
     "campaigns.invalid": "Campagna non valida",
     "campaigns.invalid": "Campagna non valida",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "È necessaria una data per programmare la campagna.",
     "campaigns.needsSendAt": "È necessaria una data per programmare la campagna.",
     "campaigns.newCampaign": "Nuova campagna",
     "campaigns.newCampaign": "Nuova campagna",

+ 2 - 0
i18n/ml.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "ഇവിടെ ഉള്ളടക്കം നൽകുക",
     "campaigns.contentHelp": "ഇവിടെ ഉള്ളടക്കം നൽകുക",
     "campaigns.continue": "തുടരൂ",
     "campaigns.continue": "തുടരൂ",
     "campaigns.copyOf": "{name} ന്റെ പകർപ്പ്",
     "campaigns.copyOf": "{name} ന്റെ പകർപ്പ്",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "തിയതിയും സമയവും",
     "campaigns.dateAndTime": "തിയതിയും സമയവും",
     "campaigns.ended": "അവസാനിച്ചു",
     "campaigns.ended": "അവസാനിച്ചു",
     "campaigns.errorSendTest": "ടെസ്റ്റ് അയയ്ക്കുന്നത് പരാജയപ്പെട്ടു: {error}",
     "campaigns.errorSendTest": "ടെസ്റ്റ് അയയ്ക്കുന്നത് പരാജയപ്പെട്ടു: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "പ്രേക്ഷകൻ",
     "campaigns.fromAddress": "പ്രേക്ഷകൻ",
     "campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "നിങ്ങളുടെ പേര് <noreply@yoursite.com>",
     "campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്",
     "campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.",
     "campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.",
     "campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ",
     "campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ",

+ 2 - 0
i18n/nl.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Inhoud hier",
     "campaigns.contentHelp": "Inhoud hier",
     "campaigns.continue": "Hervatten",
     "campaigns.continue": "Hervatten",
     "campaigns.copyOf": "Kopie van {name}",
     "campaigns.copyOf": "Kopie van {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Datum en tijd",
     "campaigns.dateAndTime": "Datum en tijd",
     "campaigns.ended": "Beëindigd",
     "campaigns.ended": "Beëindigd",
     "campaigns.errorSendTest": "Fout bij verzenden test: {error}",
     "campaigns.errorSendTest": "Fout bij verzenden test: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Afzender",
     "campaigns.fromAddress": "Afzender",
     "campaigns.fromAddressPlaceholder": "Jouw Naam <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Jouw Naam <noreply@yoursite.com>",
     "campaigns.invalid": "Ongeldige campagne",
     "campaigns.invalid": "Ongeldige campagne",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Campagne heeft een datum nodig om ingepland te worden.",
     "campaigns.needsSendAt": "Campagne heeft een datum nodig om ingepland te worden.",
     "campaigns.newCampaign": "Nieuwe campagne",
     "campaigns.newCampaign": "Nieuwe campagne",

+ 2 - 0
i18n/pl.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Zgoda tutaj",
     "campaigns.contentHelp": "Zgoda tutaj",
     "campaigns.continue": "Kontynuuj",
     "campaigns.continue": "Kontynuuj",
     "campaigns.copyOf": "Kopia {name}",
     "campaigns.copyOf": "Kopia {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Data i czas",
     "campaigns.dateAndTime": "Data i czas",
     "campaigns.ended": "Zakończona",
     "campaigns.ended": "Zakończona",
     "campaigns.errorSendTest": "Błąd wysyłania testu: {error}",
     "campaigns.errorSendTest": "Błąd wysyłania testu: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Adres od",
     "campaigns.fromAddress": "Adres od",
     "campaigns.fromAddressPlaceholder": "Twoja Nazwa <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Twoja Nazwa <noreply@yoursite.com>",
     "campaigns.invalid": "Nieprawidłowa kampania",
     "campaigns.invalid": "Nieprawidłowa kampania",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Kampania wymaga daty w celu zaplanowania.",
     "campaigns.needsSendAt": "Kampania wymaga daty w celu zaplanowania.",
     "campaigns.newCampaign": "Nowa kampania",
     "campaigns.newCampaign": "Nowa kampania",

+ 2 - 0
i18n/pt-BR.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Conteúdo aqui",
     "campaigns.contentHelp": "Conteúdo aqui",
     "campaigns.continue": "Continuar",
     "campaigns.continue": "Continuar",
     "campaigns.copyOf": "Cópia de {name}",
     "campaigns.copyOf": "Cópia de {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Data e hora",
     "campaigns.dateAndTime": "Data e hora",
     "campaigns.ended": "Finalizada",
     "campaigns.ended": "Finalizada",
     "campaigns.errorSendTest": "Erro ao enviar o teste: {error}",
     "campaigns.errorSendTest": "Erro ao enviar o teste: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Endereço do remetente",
     "campaigns.fromAddress": "Endereço do remetente",
     "campaigns.fromAddressPlaceholder": "Seu Nome <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Seu Nome <noreply@yoursite.com>",
     "campaigns.invalid": "Campanha inválida",
     "campaigns.invalid": "Campanha inválida",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "A campanha precisa de uma data para ser programada.",
     "campaigns.needsSendAt": "A campanha precisa de uma data para ser programada.",
     "campaigns.newCampaign": "Nova campanha",
     "campaigns.newCampaign": "Nova campanha",

+ 2 - 0
i18n/pt.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Conteúdo aqui",
     "campaigns.contentHelp": "Conteúdo aqui",
     "campaigns.continue": "Continuar",
     "campaigns.continue": "Continuar",
     "campaigns.copyOf": "Cópia de {name}",
     "campaigns.copyOf": "Cópia de {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Dia e hora",
     "campaigns.dateAndTime": "Dia e hora",
     "campaigns.ended": "Terminada",
     "campaigns.ended": "Terminada",
     "campaigns.errorSendTest": "Erro ao enviar teste: {error}",
     "campaigns.errorSendTest": "Erro ao enviar teste: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Endereço do Remetente",
     "campaigns.fromAddress": "Endereço do Remetente",
     "campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
     "campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
     "campaigns.invalid": "Campanha inválida",
     "campaigns.invalid": "Campanha inválida",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
     "campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
     "campaigns.newCampaign": "Nova campanha",
     "campaigns.newCampaign": "Nova campanha",

+ 2 - 0
i18n/ro.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Conținut aici",
     "campaigns.contentHelp": "Conținut aici",
     "campaigns.continue": "Continuă",
     "campaigns.continue": "Continuă",
     "campaigns.copyOf": "Copie a {nume}",
     "campaigns.copyOf": "Copie a {nume}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Dată și oră",
     "campaigns.dateAndTime": "Dată și oră",
     "campaigns.ended": "Terminat",
     "campaigns.ended": "Terminat",
     "campaigns.errorSendTest": "Eroare trimitere test: {erore}",
     "campaigns.errorSendTest": "Eroare trimitere test: {erore}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "De la adresa",
     "campaigns.fromAddress": "De la adresa",
     "campaigns.fromAddressPlaceholder": "Numele tau <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Numele tau <noreply@yoursite.com>",
     "campaigns.invalid": "Campanie nevalidă",
     "campaigns.invalid": "Campanie nevalidă",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Campania are nevoie de o dată pentru a fi programată",
     "campaigns.needsSendAt": "Campania are nevoie de o dată pentru a fi programată",
     "campaigns.newCampaign": "Campanie nouă",
     "campaigns.newCampaign": "Campanie nouă",

+ 2 - 0
i18n/ru.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "Содержимое",
     "campaigns.contentHelp": "Содержимое",
     "campaigns.continue": "Продолжить",
     "campaigns.continue": "Продолжить",
     "campaigns.copyOf": "Копия {name}",
     "campaigns.copyOf": "Копия {name}",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Дата и время",
     "campaigns.dateAndTime": "Дата и время",
     "campaigns.ended": "Окончено",
     "campaigns.ended": "Окончено",
     "campaigns.errorSendTest": "Ошибка отправки теста: {error}",
     "campaigns.errorSendTest": "Ошибка отправки теста: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Адрес отправителя",
     "campaigns.fromAddress": "Адрес отправителя",
     "campaigns.fromAddressPlaceholder": "Ваше имя <noreply@yoursite.com>",
     "campaigns.fromAddressPlaceholder": "Ваше имя <noreply@yoursite.com>",
     "campaigns.invalid": "Неверная компания",
     "campaigns.invalid": "Неверная компания",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Разметка",
     "campaigns.markdown": "Разметка",
     "campaigns.needsSendAt": "Для планирования компании необходима дата.",
     "campaigns.needsSendAt": "Для планирования компании необходима дата.",
     "campaigns.newCampaign": "Новая компания",
     "campaigns.newCampaign": "Новая компания",

+ 2 - 0
i18n/tr.json

@@ -21,6 +21,7 @@
     "campaigns.contentHelp": "İçerik buraya",
     "campaigns.contentHelp": "İçerik buraya",
     "campaigns.continue": "Devam et",
     "campaigns.continue": "Devam et",
     "campaigns.copyOf": "{name} - Kopyası",
     "campaigns.copyOf": "{name} - Kopyası",
+    "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
     "campaigns.dateAndTime": "Tarih ve saat",
     "campaigns.dateAndTime": "Tarih ve saat",
     "campaigns.ended": "Bitti",
     "campaigns.ended": "Bitti",
     "campaigns.errorSendTest": "Test gönderirken hata: {error}",
     "campaigns.errorSendTest": "Test gönderirken hata: {error}",
@@ -35,6 +36,7 @@
     "campaigns.fromAddress": "Gelen adres",
     "campaigns.fromAddress": "Gelen adres",
     "campaigns.fromAddressPlaceholder": "isminiz <cevap-verme@siteniz.com>",
     "campaigns.fromAddressPlaceholder": "isminiz <cevap-verme@siteniz.com>",
     "campaigns.invalid": "Yanlış tanımlı kapmanya",
     "campaigns.invalid": "Yanlış tanımlı kapmanya",
+    "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
     "campaigns.markdown": "Markdown",
     "campaigns.markdown": "Markdown",
     "campaigns.needsSendAt": "Kampanya için tanımlanmış bir tarih gerekli.",
     "campaigns.needsSendAt": "Kampanya için tanımlanmış bir tarih gerekli.",
     "campaigns.newCampaign": "Yeni kampanya",
     "campaigns.newCampaign": "Yeni kampanya",

+ 9 - 0
internal/manager/manager.go

@@ -319,6 +319,15 @@ func (m *Manager) worker() {
 				h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
 				h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
 			}
 			}
 
 
+			// Attach any custom headers.
+			if len(msg.Campaign.Headers) > 0 {
+				for _, set := range msg.Campaign.Headers {
+					for hdr, val := range set {
+						h.Add(hdr, val)
+					}
+				}
+			}
+
 			out.Headers = h
 			out.Headers = h
 
 
 			if err := m.messengers[msg.Campaign.Messenger].Push(out); err != nil {
 			if err := m.messengers[msg.Campaign.Messenger].Push(out); err != nil {

+ 8 - 6
internal/messenger/postback/postback.go

@@ -24,9 +24,10 @@ type postback struct {
 }
 }
 
 
 type campaign struct {
 type campaign struct {
-	UUID string   `db:"uuid" json:"uuid"`
-	Name string   `db:"name" json:"name"`
-	Tags []string `db:"tags" json:"tags"`
+	UUID    string         `db:"uuid" json:"uuid"`
+	Name    string         `db:"name" json:"name"`
+	Headers models.Headers `db:"headers" json:"headers"`
+	Tags    []string       `db:"tags" json:"tags"`
 }
 }
 
 
 type recipient struct {
 type recipient struct {
@@ -100,9 +101,10 @@ func (p *Postback) Push(m messenger.Message) error {
 
 
 	if m.Campaign != nil {
 	if m.Campaign != nil {
 		pb.Campaign = &campaign{
 		pb.Campaign = &campaign{
-			UUID: m.Campaign.UUID,
-			Name: m.Campaign.Name,
-			Tags: m.Campaign.Tags,
+			UUID:    m.Campaign.UUID,
+			Name:    m.Campaign.Name,
+			Headers: m.Campaign.Headers,
+			Tags:    m.Campaign.Tags,
 		}
 		}
 	}
 	}
 
 

+ 88 - 19
internal/messenger/postback/postback_easyjson.go

@@ -179,6 +179,43 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback2(in *
 			out.UUID = string(in.String())
 			out.UUID = string(in.String())
 		case "name":
 		case "name":
 			out.Name = string(in.String())
 			out.Name = string(in.String())
+		case "headers":
+			if in.IsNull() {
+				in.Skip()
+				out.Headers = nil
+			} else {
+				in.Delim('[')
+				if out.Headers == nil {
+					if !in.IsDelim(']') {
+						out.Headers = make(models.Headers, 0, 8)
+					} else {
+						out.Headers = models.Headers{}
+					}
+				} else {
+					out.Headers = (out.Headers)[:0]
+				}
+				for !in.IsDelim(']') {
+					var v4 map[string]string
+					if in.IsNull() {
+						in.Skip()
+					} else {
+						in.Delim('{')
+						v4 = make(map[string]string)
+						for !in.IsDelim('}') {
+							key := string(in.String())
+							in.WantColon()
+							var v5 string
+							v5 = string(in.String())
+							(v4)[key] = v5
+							in.WantComma()
+						}
+						in.Delim('}')
+					}
+					out.Headers = append(out.Headers, v4)
+					in.WantComma()
+				}
+				in.Delim(']')
+			}
 		case "tags":
 		case "tags":
 			if in.IsNull() {
 			if in.IsNull() {
 				in.Skip()
 				in.Skip()
@@ -195,9 +232,9 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback2(in *
 					out.Tags = (out.Tags)[:0]
 					out.Tags = (out.Tags)[:0]
 				}
 				}
 				for !in.IsDelim(']') {
 				for !in.IsDelim(']') {
-					var v4 string
-					v4 = string(in.String())
-					out.Tags = append(out.Tags, v4)
+					var v6 string
+					v6 = string(in.String())
+					out.Tags = append(out.Tags, v6)
 					in.WantComma()
 					in.WantComma()
 				}
 				}
 				in.Delim(']')
 				in.Delim(']')
@@ -226,6 +263,38 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback2(out
 		out.RawString(prefix)
 		out.RawString(prefix)
 		out.String(string(in.Name))
 		out.String(string(in.Name))
 	}
 	}
+	{
+		const prefix string = ",\"headers\":"
+		out.RawString(prefix)
+		if in.Headers == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 {
+			out.RawString("null")
+		} else {
+			out.RawByte('[')
+			for v7, v8 := range in.Headers {
+				if v7 > 0 {
+					out.RawByte(',')
+				}
+				if v8 == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 {
+					out.RawString(`null`)
+				} else {
+					out.RawByte('{')
+					v9First := true
+					for v9Name, v9Value := range v8 {
+						if v9First {
+							v9First = false
+						} else {
+							out.RawByte(',')
+						}
+						out.String(string(v9Name))
+						out.RawByte(':')
+						out.String(string(v9Value))
+					}
+					out.RawByte('}')
+				}
+			}
+			out.RawByte(']')
+		}
+	}
 	{
 	{
 		const prefix string = ",\"tags\":"
 		const prefix string = ",\"tags\":"
 		out.RawString(prefix)
 		out.RawString(prefix)
@@ -233,11 +302,11 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback2(out
 			out.RawString("null")
 			out.RawString("null")
 		} else {
 		} else {
 			out.RawByte('[')
 			out.RawByte('[')
-			for v5, v6 := range in.Tags {
-				if v5 > 0 {
+			for v10, v11 := range in.Tags {
+				if v10 > 0 {
 					out.RawByte(',')
 					out.RawByte(',')
 				}
 				}
-				out.String(string(v6))
+				out.String(string(v11))
 			}
 			}
 			out.RawByte(']')
 			out.RawByte(']')
 		}
 		}
@@ -278,15 +347,15 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback1(in *
 				for !in.IsDelim('}') {
 				for !in.IsDelim('}') {
 					key := string(in.String())
 					key := string(in.String())
 					in.WantColon()
 					in.WantColon()
-					var v7 interface{}
-					if m, ok := v7.(easyjson.Unmarshaler); ok {
+					var v12 interface{}
+					if m, ok := v12.(easyjson.Unmarshaler); ok {
 						m.UnmarshalEasyJSON(in)
 						m.UnmarshalEasyJSON(in)
-					} else if m, ok := v7.(json.Unmarshaler); ok {
+					} else if m, ok := v12.(json.Unmarshaler); ok {
 						_ = m.UnmarshalJSON(in.Raw())
 						_ = m.UnmarshalJSON(in.Raw())
 					} else {
 					} else {
-						v7 = in.Interface()
+						v12 = in.Interface()
 					}
 					}
-					(out.Attribs)[key] = v7
+					(out.Attribs)[key] = v12
 					in.WantComma()
 					in.WantComma()
 				}
 				}
 				in.Delim('}')
 				in.Delim('}')
@@ -329,21 +398,21 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback1(out
 			out.RawString(`null`)
 			out.RawString(`null`)
 		} else {
 		} else {
 			out.RawByte('{')
 			out.RawByte('{')
-			v8First := true
-			for v8Name, v8Value := range in.Attribs {
-				if v8First {
-					v8First = false
+			v13First := true
+			for v13Name, v13Value := range in.Attribs {
+				if v13First {
+					v13First = false
 				} else {
 				} else {
 					out.RawByte(',')
 					out.RawByte(',')
 				}
 				}
-				out.String(string(v8Name))
+				out.String(string(v13Name))
 				out.RawByte(':')
 				out.RawByte(':')
-				if m, ok := v8Value.(easyjson.Marshaler); ok {
+				if m, ok := v13Value.(easyjson.Marshaler); ok {
 					m.MarshalEasyJSON(out)
 					m.MarshalEasyJSON(out)
-				} else if m, ok := v8Value.(json.Marshaler); ok {
+				} else if m, ok := v13Value.(json.Marshaler); ok {
 					out.Raw(m.MarshalJSON())
 					out.Raw(m.MarshalJSON())
 				} else {
 				} else {
-					out.Raw(json.Marshal(v8Value))
+					out.Raw(json.Marshal(v13Value))
 				}
 				}
 			}
 			}
 			out.RawByte('}')
 			out.RawByte('}')

+ 5 - 1
internal/migrations/v2.1.0.go

@@ -8,7 +8,7 @@ import (
 
 
 // V2_1_0 performs the DB migrations for v.2.1.0.
 // V2_1_0 performs the DB migrations for v.2.1.0.
 func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
-	// Insert into appearance related settings.
+	// Insert appearance related settings.
 	if _, err := db.Exec(`
 	if _, err := db.Exec(`
 		INSERT INTO settings (key, value) VALUES
 		INSERT INTO settings (key, value) VALUES
  			('appearance.admin.custom_css', '""'),
  			('appearance.admin.custom_css', '""'),
@@ -34,5 +34,9 @@ func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 		return err
 		return err
 	}
 	}
 
 
+	if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil {
+		return err
+	}
+
 	return nil
 	return nil
 }
 }

+ 42 - 0
models/models.go

@@ -72,6 +72,10 @@ const (
 	BounceTypeSoft = "soft"
 	BounceTypeSoft = "soft"
 )
 )
 
 
+// Headers represents an array of string maps used to represent SMTP, HTTP headers etc.
+// similar to url.Values{}
+type Headers []map[string]string
+
 // regTplFunc represents contains a regular expression for wrapping and
 // regTplFunc represents contains a regular expression for wrapping and
 // substituting a Go template function from the user's shorthand to a full
 // substituting a Go template function from the user's shorthand to a full
 // function call.
 // function call.
@@ -193,6 +197,7 @@ type Campaign struct {
 	Status      string         `db:"status" json:"status"`
 	Status      string         `db:"status" json:"status"`
 	ContentType string         `db:"content_type" json:"content_type"`
 	ContentType string         `db:"content_type" json:"content_type"`
 	Tags        pq.StringArray `db:"tags" json:"tags"`
 	Tags        pq.StringArray `db:"tags" json:"tags"`
+	Headers     Headers        `db:"headers" json:"headers"`
 	TemplateID  int            `db:"template_id" json:"template_id"`
 	TemplateID  int            `db:"template_id" json:"template_id"`
 	Messenger   string         `db:"messenger" json:"messenger"`
 	Messenger   string         `db:"messenger" json:"messenger"`
 
 
@@ -468,3 +473,40 @@ func (s Subscriber) LastName() string {
 
 
 	return s.Name
 	return s.Name
 }
 }
+
+// Scan implements the sql.Scanner interface.
+func (h *Headers) Scan(src interface{}) error {
+	var b []byte
+	switch src := src.(type) {
+	case []byte:
+		b = src
+	case string:
+		b = []byte(src)
+	case nil:
+		return nil
+	}
+
+	if err := json.Unmarshal(b, h); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Value implements the driver.Valuer interface.
+func (h Headers) Value() (driver.Value, error) {
+	if h == nil {
+		return nil, nil
+	}
+
+	if n := len(h); n > 0 {
+		b, err := json.Marshal(h)
+		if err != nil {
+			return nil, err
+		}
+
+		return b, nil
+	}
+
+	return "[]", nil
+}

+ 14 - 13
queries.sql

@@ -388,16 +388,16 @@ WITH campLists AS (
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
     -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
     SELECT lists.id AS list_id, campaign_id, optin FROM lists
     SELECT lists.id AS list_id, campaign_id, optin FROM lists
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
     INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
-    WHERE lists.id = ANY($13::INT[])
+    WHERE lists.id = ANY($14::INT[])
 ),
 ),
 tpl AS (
 tpl AS (
     -- If there's no template_id given, use the defualt template.
     -- If there's no template_id given, use the defualt template.
-    SELECT (CASE WHEN $12 = 0 THEN id ELSE $12 END) AS id FROM templates WHERE is_default IS TRUE
+    SELECT (CASE WHEN $13 = 0 THEN id ELSE $13 END) AS id FROM templates WHERE is_default IS TRUE
 ),
 ),
 counts AS (
 counts AS (
     SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
     SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
     FROM subscribers
     FROM subscribers
-    LEFT JOIN campLists ON (campLists.campaign_id = ANY($13::INT[]))
+    LEFT JOIN campLists ON (campLists.campaign_id = ANY($14::INT[]))
     LEFT JOIN subscriber_lists ON (
     LEFT JOIN subscriber_lists ON (
         subscriber_lists.status != 'unsubscribed' AND
         subscriber_lists.status != 'unsubscribed' AND
         subscribers.id = subscriber_lists.subscriber_id AND
         subscribers.id = subscriber_lists.subscriber_id AND
@@ -407,16 +407,16 @@ counts AS (
         -- any status except for 'unsubscribed' (already excluded above) works.
         -- any status except for 'unsubscribed' (already excluded above) works.
         (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
         (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
     )
     )
-    WHERE subscriber_lists.list_id=ANY($13::INT[])
+    WHERE subscriber_lists.list_id=ANY($14::INT[])
     AND subscribers.status='enabled'
     AND subscribers.status='enabled'
 ),
 ),
 camp AS (
 camp AS (
-    INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, tags, messenger, template_id, to_send, max_subscriber_id)
-        SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
+    INSERT INTO campaigns (uuid, type, name, subject, from_email, body, altbody, content_type, send_at, headers, tags, messenger, template_id, to_send, max_subscriber_id)
+        SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (SELECT id FROM tpl), (SELECT to_send FROM counts), (SELECT max_sub_id FROM counts)
         RETURNING id
         RETURNING id
 )
 )
 INSERT INTO campaign_lists (campaign_id, list_id, list_name)
 INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-    (SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($13::INT[]))
+    (SELECT (SELECT id FROM camp), id, name FROM lists WHERE id=ANY($14::INT[]))
     RETURNING (SELECT id FROM camp);
     RETURNING (SELECT id FROM camp);
 
 
 -- name: query-campaigns
 -- name: query-campaigns
@@ -428,7 +428,7 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
 -- with every resultant row.
 -- with every resultant row.
 SELECT  c.id, c.uuid, c.name, c.subject, c.from_email,
 SELECT  c.id, c.uuid, c.name, c.subject, c.from_email,
         c.messenger, c.started_at, c.to_send, c.sent, c.type,
         c.messenger, c.started_at, c.to_send, c.sent, c.type,
-        c.body, c.altbody, c.send_at, c.status, c.content_type, c.tags,
+        c.body, c.altbody, c.send_at, c.headers, c.status, c.content_type, c.tags,
         c.template_id, c.created_at, c.updated_at,
         c.template_id, c.created_at, c.updated_at,
         COUNT(*) OVER () AS total,
         COUNT(*) OVER () AS total,
         (
         (
@@ -666,18 +666,19 @@ WITH camp AS (
         content_type=$7::content_type,
         content_type=$7::content_type,
         send_at=$8::TIMESTAMP WITH TIME ZONE,
         send_at=$8::TIMESTAMP WITH TIME ZONE,
         status=(CASE WHEN NOT $9 THEN 'draft' ELSE status END),
         status=(CASE WHEN NOT $9 THEN 'draft' ELSE status END),
-        tags=$10::VARCHAR(100)[],
-        messenger=$11,
-        template_id=$12,
+        headers=$10,
+        tags=$11::VARCHAR(100)[],
+        messenger=$12,
+        template_id=$13,
         updated_at=NOW()
         updated_at=NOW()
     WHERE id = $1 RETURNING id
     WHERE id = $1 RETURNING id
 ),
 ),
 d AS (
 d AS (
     -- Reset list relationships
     -- Reset list relationships
-    DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($13))
+    DELETE FROM campaign_lists WHERE campaign_id = $1 AND NOT(list_id = ANY($14))
 )
 )
 INSERT INTO campaign_lists (campaign_id, list_id, list_name)
 INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-    (SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($13::INT[]))
+    (SELECT $1 as campaign_id, id, name FROM lists WHERE id=ANY($14::INT[]))
     ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
     ON CONFLICT (campaign_id, list_id) DO UPDATE SET list_name = EXCLUDED.list_name;
 
 
 -- name: update-campaign-counts
 -- name: update-campaign-counts

+ 1 - 0
schema.sql

@@ -78,6 +78,7 @@ CREATE TABLE campaigns (
     altbody          TEXT NULL,
     altbody          TEXT NULL,
     content_type     content_type NOT NULL DEFAULT 'richtext',
     content_type     content_type NOT NULL DEFAULT 'richtext',
     send_at          TIMESTAMP WITH TIME ZONE,
     send_at          TIMESTAMP WITH TIME ZONE,
+    headers          JSONB NOT NULL DEFAULT '[]',
     status           campaign_status NOT NULL DEFAULT 'draft',
     status           campaign_status NOT NULL DEFAULT 'draft',
     tags             VARCHAR(100)[],
     tags             VARCHAR(100)[],