From 5fc28a733c7d6d9e6e3a2d4cc699e009c142bf35 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Tue, 11 Apr 2023 11:33:13 +0530 Subject: [PATCH] Add support for variable bounce processing actions. - Add support for `complaint` to the SES bounce processor. - Add support for `hard/soft` to Sendgrid bounce processor. - Add new bounce actions `None` and `Unsubscribe`. - Add per type (`soft/hard/complaint`) bounce rule configuration to admin settings UI. - Refactor Cypress bounce tests. --- cmd/bounce.go | 10 ++-- cmd/main.go | 13 ++-- frontend/cypress/e2e/bounces.cy.js | 80 ++++++++++--------------- frontend/src/views/Bounces.vue | 6 ++ frontend/src/views/Settings.vue | 4 +- frontend/src/views/settings/bounces.vue | 44 +++++++++----- i18n/ca.json | 8 +++ i18n/cs-cz.json | 8 +++ i18n/cy.json | 8 +++ i18n/de.json | 8 +++ i18n/en.json | 6 +- i18n/es.json | 8 +++ i18n/fi.json | 8 +++ i18n/fr.json | 8 +++ i18n/hu.json | 8 +++ i18n/it.json | 8 +++ i18n/jp.json | 8 +++ i18n/ml.json | 8 +++ i18n/nl.json | 8 +++ i18n/pl.json | 8 +++ i18n/pt-BR.json | 8 +++ i18n/pt.json | 8 +++ i18n/ro.json | 8 +++ i18n/ru.json | 8 +++ i18n/se.json | 8 +++ i18n/sk.json | 8 +++ i18n/tr.json | 8 +++ i18n/vi.json | 8 +++ i18n/zh-CN.json | 8 +++ i18n/zh-TW.json | 8 +++ internal/bounce/webhooks/sendgrid.go | 14 +++-- internal/bounce/webhooks/ses.go | 10 +++- internal/core/bounces.go | 9 ++- internal/core/core.go | 6 +- internal/migrations/v2.5.0.go | 7 ++- models/models.go | 5 +- models/settings.go | 18 +++--- queries.sql | 16 ++--- schema.sql | 3 +- 39 files changed, 326 insertions(+), 109 deletions(-) diff --git a/cmd/bounce.go b/cmd/bounce.go index f19b8ad9..0c2231a5 100644 --- a/cmd/bounce.go +++ b/cmd/bounce.go @@ -132,7 +132,7 @@ func handleBounceWebhook(c echo.Context) error { case service == "": var b models.Bounce if err := json.Unmarshal(rawReq, &b); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")+":"+err.Error()) } if bv, err := validateBounceFields(b, app); err != nil { @@ -207,11 +207,11 @@ func handleBounceWebhook(c echo.Context) error { func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) { if b.Email == "" && b.SubscriberUUID == "" { - return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email / subscriber_uuid")) } if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) { - return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID")) + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_uuid")) } if b.Email != "" { @@ -222,8 +222,8 @@ func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) { b.Email = em } - if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft { - return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft && b.Type != models.BounceTypeComplaint { + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "type")) } return b, nil diff --git a/cmd/main.go b/cmd/main.go index 04dfb047..e3f4d623 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -184,18 +184,21 @@ func main() { // Load i18n language map. app.i18n = initI18n(app.constants.Lang, fs) - - app.core = core.New(&core.Opt{ + cOpt := &core.Opt{ Constants: core.Constants{ SendOptinConfirmation: app.constants.SendOptinConfirmation, - MaxBounceCount: ko.MustInt("bounce.count"), - BounceAction: ko.MustString("bounce.action"), }, Queries: queries, DB: db, I18n: app.i18n, Log: lo, - }, &core.Hooks{ + } + + if err := ko.Unmarshal("bounce.actions", &cOpt.Constants.BounceActions); err != nil { + lo.Fatalf("error unmarshalling bounce config: %v", err) + } + + app.core = core.New(cOpt, &core.Hooks{ SendOptinConfirmation: sendOptinConfirmationHook(app), }) diff --git a/frontend/cypress/e2e/bounces.cy.js b/frontend/cypress/e2e/bounces.cy.js index 0cb16c75..b9146572 100644 --- a/frontend/cypress/e2e/bounces.cy.js +++ b/frontend/cypress/e2e/bounces.cy.js @@ -10,68 +10,52 @@ describe('Bounces', () => { cy.get('.b-tabs nav a').eq(6).click(); cy.get('[data-cy=btn-enable-bounce] .switch').click(); cy.get('[data-cy=btn-enable-bounce-webhook] .switch').click(); - cy.get('[data-cy=btn-bounce-count] .plus').click(); cy.get('[data-cy=btn-save]').click(); cy.wait(2000); }); - it('Post bounces', () => { // Get campaign. let camp = {}; cy.request(`${apiUrl}/api/campaigns`).then((resp) => { camp = resp.body.data.results[0]; - }) - cy.then(() => { - console.log("campaign is ", camp.uuid); - }) - + }).then(() => { + console.log('campaign is ', camp.uuid); + }); // Get subscribers. + let subs = []; cy.request(`${apiUrl}/api/subscribers`).then((resp) => { subs = resp.body.data.results; - console.log(subs) + }).then(() => { + // Register soft bounces do nothing. + let sub = {}; + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); + cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { + sub = resp.body.data; + }).then(() => { + cy.expect(sub.status).to.equal('enabled'); + }); + + // Hard bounces blocklist. + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); + cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { + sub = resp.body.data; + }).then(() => { + cy.expect(sub.status).to.equal('blocklisted'); + }); + + // Complaint bounces delete. + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); + cy.request({ url: `${apiUrl}/api/subscribers/${subs[1].id}`, failOnStatusCode: false }).then((resp) => { + expect(resp.status).to.eq(400); + }); + + cy.loginAndVisit('/subscribers/bounces'); }); - - cy.then(() => { - console.log(`got ${subs.length} subscribers`); - - // Post bounces. Blocklist the 1st sub. - cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: "api", type: "hard", email: subs[0].email }); - cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: "api", type: "hard", campaign_uuid: camp.uuid, email: subs[0].email }); - cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: "api", type: "hard", campaign_uuid: camp.uuid, subscriber_uuid: subs[0].uuid }); - - for (let i = 0; i < 2; i++) { - cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: "api", type: "soft", campaign_uuid: camp.uuid, subscriber_uuid: subs[1].uuid }); - } - }); - - cy.wait(250); }); - - it('Opens bounces page', () => { - cy.loginAndVisit('/subscribers/bounces'); - cy.wait(250); - cy.get('tbody tr').its('length').should('eq', 5); - }); - - it('Delete bounce', () => { - cy.get('tbody tr:last-child [data-cy="btn-delete"]').click(); - cy.get('.modal button.is-primary').click(); - cy.wait(250); - cy.get('tbody tr').its('length').should('eq', 4); - }); - - it('Check subscriber statuses', () => { - cy.loginAndVisit(`/subscribers/${subs[0].id}`); - cy.wait(250); - cy.get('.modal-card-head .tag').should('have.class', 'blocklisted'); - cy.get('.modal-card-foot button[type="button"]').click(); - - cy.loginAndVisit(`/subscribers/${subs[1].id}`); - cy.wait(250); - cy.get('.modal-card-head .tag').should('have.class', 'enabled'); - }); - }); diff --git a/frontend/src/views/Bounces.vue b/frontend/src/views/Bounces.vue index f9e8b1a1..e38f25f2 100644 --- a/frontend/src/views/Bounces.vue +++ b/frontend/src/views/Bounces.vue @@ -51,6 +51,12 @@ + + + {{ $t(`bounces.${props.row.type}`) }} + + + {{ $utils.niceDate(props.row.createdAt, true) }} diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 8dad6d96..25b67f05 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -20,7 +20,7 @@
-
+
@@ -103,7 +103,7 @@ export default Vue.extend({ // formCopy is a stringified copy of the original settings against which // form is compared to detect changes. formCopy: '', - form: {}, + form: null, tab: 0, }; }, diff --git a/frontend/src/views/settings/bounces.vue b/frontend/src/views/settings/bounces.vue index 46345da9..c1714d4c 100644 --- a/frontend/src/views/settings/bounces.vue +++ b/frontend/src/views/settings/bounces.vue @@ -1,26 +1,37 @@