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.
This commit is contained in:
Kailash Nadh 2022-01-04 22:16:21 +05:30
parent 9e9ea0ef15
commit 583dab4bc6
27 changed files with 286 additions and 55 deletions

View file

@ -292,6 +292,7 @@ func handleCreateCampaign(c echo.Context) error {
o.AltBody,
o.ContentType,
o.SendAt,
o.Headers,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
@ -366,6 +367,7 @@ func handleUpdateCampaign(c echo.Context) error {
o.ContentType,
o.SendAt,
o.SendLater,
o.Headers,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
@ -603,6 +605,7 @@ func handleTestCampaign(c echo.Context) error {
camp.AltBody = req.AltBody
camp.Messenger = req.Messenger
camp.ContentType = req.ContentType
camp.Headers = req.Headers
camp.TemplateID = req.TemplateID
// 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()))
}
if len(c.Headers) == 0 {
c.Headers = make([]map[string]string, 0)
}
return c, nil
}

View file

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

View file

@ -1,4 +1,5 @@
const apiUrl = Cypress.env('apiUrl');
const headers = '[{"X-Custom": "Custom-Value"}]';
describe('Campaigns', () => {
it('Opens campaigns page', () => {
@ -38,6 +39,10 @@ describe('Campaigns', () => {
cy.wait(100);
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.
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.tags.length).to.equal(1);
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');
@ -181,18 +187,34 @@ describe('Campaigns', () => {
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'.
cy.get('button[data-cy=btn-continue]').click();
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.
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.
const htmlBody = `<strong>hello${n}</strong> \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`;
const plainBody = `hello${n} Demo Subscriber from Bengaluru`;

View file

@ -9,7 +9,7 @@
<b-tag v-if="data.type === 'optin'" :class="data.type">
{{ $t('lists.optin') }}
</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.uuid') }}: {{ data.uuid }}
</span>
@ -22,7 +22,7 @@
<div class="buttons">
<b-field grouped v-if="isEditing && canEdit">
<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">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
@ -53,7 +53,7 @@
<section class="wrap">
<div class="columns">
<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-input :maxlength="200" :ref="'focus'" v-model="form.name"
name="name" :disabled="!canEdit"
@ -124,6 +124,20 @@
</b-field>
</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 />
<b-field v-if="isNew">
@ -140,12 +154,12 @@
<h3 class="title is-size-6">{{ $t('campaigns.sendTest') }}</h3>
<b-field :message="$t('campaigns.sendTestHelp')">
<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')" />
</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') }}
</b-button>
</b-field>
@ -204,6 +218,7 @@ export default Vue.extend({
return {
isNew: false,
isEditing: false,
isHeadersVisible: false,
activeTab: 0,
data: {},
@ -216,6 +231,8 @@ export default Vue.extend({
name: '',
subject: '',
fromEmail: '',
headersStr: '[]',
headers: [],
templateId: 0,
lists: [],
tags: [],
@ -245,11 +262,32 @@ export default Vue.extend({
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 {
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,
...data,
headersStr: JSON.stringify(data.headers, null, 4),
// The structure that is populated by editor input event.
content: { contentType: data.contentType, body: data.body },
@ -280,6 +319,7 @@ export default Vue.extend({
from_email: this.form.fromEmail,
messenger: this.form.messenger,
type: 'regular',
headers: this.form.headers,
tags: this.form.tags,
template_id: this.form.templateId,
content_type: this.form.content.contentType,
@ -306,6 +346,7 @@ export default Vue.extend({
tags: this.form.tags,
send_later: this.form.sendLater,
send_at: this.form.sendLater ? this.form.sendAtDate : null,
headers: this.form.headers,
template_id: this.form.templateId,
// body: this.form.body,
};
@ -327,6 +368,7 @@ export default Vue.extend({
tags: this.form.tags,
send_later: this.form.sendLater,
send_at: this.form.sendLater ? this.form.sendAtDate : null,
headers: this.form.headers,
template_id: this.form.templateId,
content_type: this.form.content.contentType,
body: this.form.content.body,

View file

@ -142,7 +142,7 @@
<b-icon icon="plus" />{{ $t('settings.smtp.setCustomHeaders') }}</a>
</p>
<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')">
<b-input v-model="item.strEmailHeaders" name="email_headers" type="textarea"
placeholder='[{"X-Custom": "value"}, {"X-Custom2": "value"}]' />

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Obsah zde",
"campaigns.continue": "Pokračovat",
"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.ended": "Ukončeno",
"campaigns.errorSendTest": "Chyba při odesílání testu: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Z adresy",
"campaigns.fromAddressPlaceholder": "Vaše jméno <noreply@yoursite.com>",
"campaigns.invalid": "Neplatná kampaň",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Sleva",
"campaigns.needsSendAt": "Kampaň musí mít naplánované datum.",
"campaigns.newCampaign": "Nová kampaň",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Inhalt hier",
"campaigns.continue": "Fortsetzen",
"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.ended": "Abgeschlossen",
"campaigns.errorSendTest": "Fehler beim Senden der Testmail: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Absender",
"campaigns.fromAddressPlaceholder": "Dein Name <noreply@deineseite.de>",
"campaigns.invalid": "Ungültige Kampagne",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Die Kampagne benötigt ein `send_at` Sendedatum, um automatisch verschickt zu werden.",
"campaigns.newCampaign": "Neue Kampagne",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Content here",
"campaigns.continue": "Continue",
"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.ended": "Ended",
"campaigns.errorSendTest": "Error sending test: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
"campaigns.newCampaign": "New campaign",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Contenido aqui",
"campaigns.continue": "Continuar",
"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.ended": "Finalizado",
"campaigns.errorSendTest": "Error al enviar la prueba: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Dirección origen",
"campaigns.fromAddressPlaceholder": "Su Nombre <noresponder@susitio.com>",
"campaigns.invalid": "Campaña no válida",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Una campaña necesita una fecha pra ser agendada.",
"campaigns.newCampaign": "Nueva campaña",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Rédigez le contenu ici.",
"campaigns.continue": "Continuer",
"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.ended": "Terminée",
"campaigns.errorSendTest": "Erreur lors de l'envoi du test : {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Adresse d'envoi",
"campaigns.fromAddressPlaceholder": "Nom à afficher <noreply@votresite.com>",
"campaigns.invalid": "Campagne non valide",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Une date est nécessaire pour planifier la campagne.",
"campaigns.newCampaign": "Nouvelle campagne",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Tartalom itt",
"campaigns.continue": "Folytatás",
"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.ended": "Befejezett",
"campaigns.errorSendTest": "Hiba a teszt küldésekor: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Címről",
"campaigns.fromAddressPlaceholder": "A neved <noreply@yoursite.com>",
"campaigns.invalid": "Érvénytelen kampány",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Csökkentés",
"campaigns.needsSendAt": "A kampányhoz dátumot kell beállítani.",
"campaigns.newCampaign": "Új kampány",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Contenuto qui",
"campaigns.continue": "Continuare",
"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.ended": "Finito",
"campaigns.errorSendTest": "Errore durante il test di invio: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Mittente",
"campaigns.fromAddressPlaceholder": "Tuo nome <noreply@tuosito.com>",
"campaigns.invalid": "Campagna non valida",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "È necessaria una data per programmare la campagna.",
"campaigns.newCampaign": "Nuova campagna",

View file

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

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Inhoud hier",
"campaigns.continue": "Hervatten",
"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.ended": "Beëindigd",
"campaigns.errorSendTest": "Fout bij verzenden test: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Afzender",
"campaigns.fromAddressPlaceholder": "Jouw Naam <noreply@yoursite.com>",
"campaigns.invalid": "Ongeldige campagne",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campagne heeft een datum nodig om ingepland te worden.",
"campaigns.newCampaign": "Nieuwe campagne",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Zgoda tutaj",
"campaigns.continue": "Kontynuuj",
"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.ended": "Zakończona",
"campaigns.errorSendTest": "Błąd wysyłania testu: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Adres od",
"campaigns.fromAddressPlaceholder": "Twoja Nazwa <noreply@yoursite.com>",
"campaigns.invalid": "Nieprawidłowa kampania",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Kampania wymaga daty w celu zaplanowania.",
"campaigns.newCampaign": "Nowa kampania",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Conteúdo aqui",
"campaigns.continue": "Continuar",
"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.ended": "Finalizada",
"campaigns.errorSendTest": "Erro ao enviar o teste: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Endereço do remetente",
"campaigns.fromAddressPlaceholder": "Seu Nome <noreply@yoursite.com>",
"campaigns.invalid": "Campanha inválida",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "A campanha precisa de uma data para ser programada.",
"campaigns.newCampaign": "Nova campanha",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Conteúdo aqui",
"campaigns.continue": "Continuar",
"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.ended": "Terminada",
"campaigns.errorSendTest": "Erro ao enviar teste: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Endereço do Remetente",
"campaigns.fromAddressPlaceholder": "O Teu Nome <noreply@oteusite.com>",
"campaigns.invalid": "Campanha inválida",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "A campanha necessita de uma data para ser agendada.",
"campaigns.newCampaign": "Nova campanha",

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "Conținut aici",
"campaigns.continue": "Continuă",
"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.ended": "Terminat",
"campaigns.errorSendTest": "Eroare trimitere test: {erore}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "De la adresa",
"campaigns.fromAddressPlaceholder": "Numele tau <noreply@yoursite.com>",
"campaigns.invalid": "Campanie nevalidă",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campania are nevoie de o dată pentru a fi programată",
"campaigns.newCampaign": "Campanie nouă",

View file

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

View file

@ -21,6 +21,7 @@
"campaigns.contentHelp": "İçerik buraya",
"campaigns.continue": "Devam et",
"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.ended": "Bitti",
"campaigns.errorSendTest": "Test gönderirken hata: {error}",
@ -35,6 +36,7 @@
"campaigns.fromAddress": "Gelen adres",
"campaigns.fromAddressPlaceholder": "isminiz <cevap-verme@siteniz.com>",
"campaigns.invalid": "Yanlış tanımlı kapmanya",
"campaigns.invalidCustomHeaders": "Invalid custom headers: {error}",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Kampanya için tanımlanmış bir tarih gerekli.",
"campaigns.newCampaign": "Yeni kampanya",

View file

@ -319,6 +319,15 @@ func (m *Manager) worker() {
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
if err := m.messengers[msg.Campaign.Messenger].Push(out); err != nil {

View file

@ -26,6 +26,7 @@ type postback struct {
type campaign struct {
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"`
}
@ -102,6 +103,7 @@ func (p *Postback) Push(m messenger.Message) error {
pb.Campaign = &campaign{
UUID: m.Campaign.UUID,
Name: m.Campaign.Name,
Headers: m.Campaign.Headers,
Tags: m.Campaign.Tags,
}
}

View file

@ -179,6 +179,43 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback2(in *
out.UUID = string(in.String())
case "name":
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":
if in.IsNull() {
in.Skip()
@ -195,9 +232,9 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback2(in *
out.Tags = (out.Tags)[:0]
}
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.Delim(']')
@ -226,6 +263,38 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback2(out
out.RawString(prefix)
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\":"
out.RawString(prefix)
@ -233,11 +302,11 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback2(out
out.RawString("null")
} else {
out.RawByte('[')
for v5, v6 := range in.Tags {
if v5 > 0 {
for v10, v11 := range in.Tags {
if v10 > 0 {
out.RawByte(',')
}
out.String(string(v6))
out.String(string(v11))
}
out.RawByte(']')
}
@ -278,15 +347,15 @@ func easyjsonDf11841fDecodeGithubComKnadhListmonkInternalMessengerPostback1(in *
for !in.IsDelim('}') {
key := string(in.String())
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)
} else if m, ok := v7.(json.Unmarshaler); ok {
} else if m, ok := v12.(json.Unmarshaler); ok {
_ = m.UnmarshalJSON(in.Raw())
} else {
v7 = in.Interface()
v12 = in.Interface()
}
(out.Attribs)[key] = v7
(out.Attribs)[key] = v12
in.WantComma()
}
in.Delim('}')
@ -329,21 +398,21 @@ func easyjsonDf11841fEncodeGithubComKnadhListmonkInternalMessengerPostback1(out
out.RawString(`null`)
} else {
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 {
out.RawByte(',')
}
out.String(string(v8Name))
out.String(string(v13Name))
out.RawByte(':')
if m, ok := v8Value.(easyjson.Marshaler); ok {
if m, ok := v13Value.(easyjson.Marshaler); ok {
m.MarshalEasyJSON(out)
} else if m, ok := v8Value.(json.Marshaler); ok {
} else if m, ok := v13Value.(json.Marshaler); ok {
out.Raw(m.MarshalJSON())
} else {
out.Raw(json.Marshal(v8Value))
out.Raw(json.Marshal(v13Value))
}
}
out.RawByte('}')

View file

@ -8,7 +8,7 @@ import (
// 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 {
// Insert into appearance related settings.
// Insert appearance related settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES
('appearance.admin.custom_css', '""'),
@ -34,5 +34,9 @@ func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
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
}

View file

@ -72,6 +72,10 @@ const (
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
// substituting a Go template function from the user's shorthand to a full
// function call.
@ -193,6 +197,7 @@ type Campaign struct {
Status string `db:"status" json:"status"`
ContentType string `db:"content_type" json:"content_type"`
Tags pq.StringArray `db:"tags" json:"tags"`
Headers Headers `db:"headers" json:"headers"`
TemplateID int `db:"template_id" json:"template_id"`
Messenger string `db:"messenger" json:"messenger"`
@ -468,3 +473,40 @@ func (s Subscriber) LastName() string {
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
}

View file

@ -388,16 +388,16 @@ WITH campLists AS (
-- 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
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 (
-- 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 (
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
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 (
subscriber_lists.status != 'unsubscribed' AND
subscribers.id = subscriber_lists.subscriber_id AND
@ -407,16 +407,16 @@ counts AS (
-- any status except for 'unsubscribed' (already excluded above) works.
(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'
),
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
)
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);
-- name: query-campaigns
@ -428,7 +428,7 @@ INSERT INTO campaign_lists (campaign_id, list_id, list_name)
-- with every resultant row.
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.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,
COUNT(*) OVER () AS total,
(
@ -666,18 +666,19 @@ WITH camp AS (
content_type=$7::content_type,
send_at=$8::TIMESTAMP WITH TIME ZONE,
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()
WHERE id = $1 RETURNING id
),
d AS (
-- 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)
(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;
-- name: update-campaign-counts

View file

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