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:
parent
9e9ea0ef15
commit
583dab4bc6
27 changed files with 286 additions and 55 deletions
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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.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,
|
||||
...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,
|
||||
|
|
|
@ -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"}]' />
|
||||
|
|
|
@ -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ň",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "പുതിയ ക്യാമ്പേയ്ൻ",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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ă",
|
||||
|
|
|
@ -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": "Новая компания",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -24,9 +24,10 @@ type postback 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 {
|
||||
|
@ -100,9 +101,10 @@ func (p *Postback) Push(m messenger.Message) error {
|
|||
|
||||
if m.Campaign != nil {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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('}')
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
27
queries.sql
27
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.
|
||||
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
|
||||
|
|
|
@ -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)[],
|
||||
|
||||
|
|
Loading…
Reference in a new issue