From 583dab4bc6ed94b718461c54f65ce59e76bb0c76 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Tue, 4 Jan 2022 22:16:21 +0530 Subject: [PATCH] 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. --- cmd/campaigns.go | 7 ++ cmd/install.go | 2 + frontend/cypress/integration/campaigns.js | 32 +++++- frontend/src/views/Campaign.vue | 62 ++++++++-- frontend/src/views/settings/smtp.vue | 2 +- i18n/cs-cz.json | 2 + i18n/de.json | 2 + i18n/en.json | 2 + i18n/es.json | 2 + i18n/fr.json | 2 + i18n/hu.json | 2 + i18n/it.json | 2 + i18n/ml.json | 2 + i18n/nl.json | 2 + i18n/pl.json | 2 + i18n/pt-BR.json | 2 + i18n/pt.json | 2 + i18n/ro.json | 2 + i18n/ru.json | 2 + i18n/tr.json | 2 + internal/manager/manager.go | 9 ++ internal/messenger/postback/postback.go | 14 ++- .../messenger/postback/postback_easyjson.go | 107 ++++++++++++++---- internal/migrations/v2.1.0.go | 6 +- models/models.go | 42 +++++++ queries.sql | 27 ++--- schema.sql | 1 + 27 files changed, 286 insertions(+), 55 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 0a5959f..a494a12 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -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 } diff --git a/cmd/install.go b/cmd/install.go index d354744..1b1be19 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -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, diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js index 28af8c8..332a54f 100644 --- a/frontend/cypress/integration/campaigns.js +++ b/frontend/cypress/integration/campaigns.js @@ -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 = `hello${n} \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`; const plainBody = `hello${n} Demo Subscriber from Bengaluru`; diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index a03a06a..2bff55b 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -9,7 +9,7 @@ {{ $t('lists.optin') }} - + {{ $t('globals.fields.id') }}: {{ data.id }} / {{ $t('globals.fields.uuid') }}: {{ data.uuid }} @@ -22,7 +22,7 @@
- {{ $t('globals.buttons.saveChanges') }} @@ -53,7 +53,7 @@
-
+
+ +
@@ -140,12 +154,12 @@

{{ $t('campaigns.sendTest') }}

- + {{ $t('campaigns.send') }} @@ -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, diff --git a/frontend/src/views/settings/smtp.vue b/frontend/src/views/settings/smtp.vue index c7734c7..dcec212 100644 --- a/frontend/src/views/settings/smtp.vue +++ b/frontend/src/views/settings/smtp.vue @@ -142,7 +142,7 @@ {{ $t('settings.smtp.setCustomHeaders') }}

diff --git a/i18n/cs-cz.json b/i18n/cs-cz.json index d84fac6..092c48b 100644 --- a/i18n/cs-cz.json +++ b/i18n/cs-cz.json @@ -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 ", "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ň", diff --git a/i18n/de.json b/i18n/de.json index f67d947..84828cd 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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 ", "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", diff --git a/i18n/en.json b/i18n/en.json index f18aa34..c87232c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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 ", "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", diff --git a/i18n/es.json b/i18n/es.json index a7ea471..56311c4 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -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 ", "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", diff --git a/i18n/fr.json b/i18n/fr.json index eb65639..be1596b 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -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 ", "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", diff --git a/i18n/hu.json b/i18n/hu.json index e7b287a..bc797bf 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -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 ", "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", diff --git a/i18n/it.json b/i18n/it.json index 24859d7..266c9f8 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -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 ", "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", diff --git a/i18n/ml.json b/i18n/ml.json index 97d3999..fa809d0 100644 --- a/i18n/ml.json +++ b/i18n/ml.json @@ -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": "നിങ്ങളുടെ പേര് ", "campaigns.invalid": "ക്യാമ്പേയ്ൻ അസാധുവാണ്", + "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}", "campaigns.markdown": "Markdown", "campaigns.needsSendAt": "ക്യാമ്പേയ്ന് `send_at` തിയതി മുൻകൂട്ടി നിശ്ചയിക്കേണ്ടതുണ്ട്.", "campaigns.newCampaign": "പുതിയ ക്യാമ്പേയ്ൻ", diff --git a/i18n/nl.json b/i18n/nl.json index 9e14e9e..9d19bfb 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -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 ", "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", diff --git a/i18n/pl.json b/i18n/pl.json index c31847c..64ff503 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -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 ", "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", diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index a5ae1ed..ba29c89 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -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 ", "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", diff --git a/i18n/pt.json b/i18n/pt.json index 6a818fc..2602583 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -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 ", "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", diff --git a/i18n/ro.json b/i18n/ro.json index 6a0ab3c..d863c2f 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -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 ", "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ă", diff --git a/i18n/ru.json b/i18n/ru.json index eb4386c..1fd3b3e 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -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": "Ваше имя ", "campaigns.invalid": "Неверная компания", + "campaigns.invalidCustomHeaders": "Invalid custom headers: {error}", "campaigns.markdown": "Разметка", "campaigns.needsSendAt": "Для планирования компании необходима дата.", "campaigns.newCampaign": "Новая компания", diff --git a/i18n/tr.json b/i18n/tr.json index 67fea55..c30566f 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -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 ", "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", diff --git a/internal/manager/manager.go b/internal/manager/manager.go index a4e3965..024048c 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -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 { diff --git a/internal/messenger/postback/postback.go b/internal/messenger/postback/postback.go index 35f0ad6..faaa78f 100644 --- a/internal/messenger/postback/postback.go +++ b/internal/messenger/postback/postback.go @@ -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, } } diff --git a/internal/messenger/postback/postback_easyjson.go b/internal/messenger/postback/postback_easyjson.go index 1a95031..f2a1e51 100644 --- a/internal/messenger/postback/postback_easyjson.go +++ b/internal/messenger/postback/postback_easyjson.go @@ -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('}') diff --git a/internal/migrations/v2.1.0.go b/internal/migrations/v2.1.0.go index a55f34c..4d41f4e 100644 --- a/internal/migrations/v2.1.0.go +++ b/internal/migrations/v2.1.0.go @@ -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 } diff --git a/models/models.go b/models/models.go index a9f3f65..db24fe6 100644 --- a/models/models.go +++ b/models/models.go @@ -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 +} diff --git a/queries.sql b/queries.sql index 70a4be5..4905503 100644 --- a/queries.sql +++ b/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 diff --git a/schema.sql b/schema.sql index c976e63..262976f 100644 --- a/schema.sql +++ b/schema.sql @@ -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)[],