Skip to content

Commit

Permalink
Add support for variable bounce processing actions.
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
knadh committed Apr 11, 2023
1 parent 13ad9ad commit 5fc28a7
Show file tree
Hide file tree
Showing 39 changed files with 326 additions and 109 deletions.
10 changes: 5 additions & 5 deletions cmd/bounce.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})

Expand Down
80 changes: 32 additions & 48 deletions frontend/cypress/e2e/bounces.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

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 });
}
}).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.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');
});

});
6 changes: 6 additions & 0 deletions frontend/src/views/Bounces.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
</router-link>
</b-table-column>

<b-table-column v-slot="props" field="type" :label="$t('globals.fields.type')" sortable>
<router-link :to="{ name: 'bounces', query: { source: props.row.type } }">
{{ $t(`bounces.${props.row.type}`) }}
</router-link>
</b-table-column>

<b-table-column v-slot="props" field="created_at"
:label="$t('globals.fields.createdAt')" sortable>
{{ $utils.niceDate(props.row.createdAt, true) }}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
</header>
<hr />

<section class="wrap">
<section class="wrap" v-if="form">
<b-tabs type="is-boxed" :animated="false" v-model="tab">
<b-tab-item :label="$t('settings.general.name')" label-position="on-border">
<general-settings :form="form" :key="key" />
Expand Down Expand Up @@ -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,
};
},
Expand Down
44 changes: 28 additions & 16 deletions frontend/src/views/settings/bounces.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
<template>
<div>
<div class="columns mb-6">
<div class="column">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enable')" data-cy="btn-enable-bounce">
<b-switch v-model="data['bounce.enabled']" name="bounce.enabled" />
</b-field>
</div>
<div class="column" :class="{'disabled': !data['bounce.enabled']}">
<b-field :label="$t('settings.bounces.count')" label-position="on-border"
:message="$t('settings.bounces.countHelp')" data-cy="btn-bounce-count">
<b-numberinput v-model="data['bounce.count']"
name="bounce.count" type="is-light"
controls-position="compact" placeholder="3" min="1" max="1000" />
</b-field>
</div>
<div class="column" :class="{'disabled': !data['bounce.enabled']}">
<b-field :label="$t('settings.bounces.action')" label-position="on-border">
<b-select name="bounce.action" v-model="data['bounce.action']">
<option value="blocklist">{{ $t('settings.bounces.blocklist') }}</option>
<option value="delete">{{ $t('settings.bounces.delete') }}</option>
</b-select>
</b-field>
<div class="column">
<div v-for="typ in bounceTypes" :key="typ" class="columns">
<div class="column is-2" :class="{'disabled': !data['bounce.enabled']}"
:label="$t('settings.bounces.count')" label-position="on-border">
{{ $t(`bounces.${typ}`) }}
</div>
<div class="column is-4" :class="{'disabled': !data['bounce.enabled']}">
<b-field :label="$t('settings.bounces.count')" label-position="on-border"
:message="$t('settings.bounces.countHelp')" data-cy="btn-bounce-count">
<b-numberinput v-model="data['bounce.actions'][typ]['count']"
name="bounce.count" type="is-light"
controls-position="compact" placeholder="3" min="1" max="1000" />
</b-field>
</div>
<div class="column is-4" :class="{'disabled': !data['bounce.enabled']}">
<b-field :label="$t('settings.bounces.action')" label-position="on-border">
<b-select name="bounce.action" v-model="data['bounce.actions'][typ]['action']"
expanded>
<option value="none">{{ $t('globals.terms.none') }}</option>
<option value="unsubscribe">{{ $t('email.unsub') }}</option>
<option value="blocklist">{{ $t('settings.bounces.blocklist') }}</option>
<option value="delete">{{ $t('globals.buttons.delete') }}</option>
</b-select>
</b-field>
</div>
</div>
</div>
</div><!-- columns -->

Expand Down Expand Up @@ -182,6 +193,7 @@ export default Vue.extend({
data() {
return {
bounceTypes: ['soft', 'hard', 'complaint'],
data: this.form,
regDuration,
};
Expand Down
8 changes: 8 additions & 0 deletions i18n/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"analytics.nonUnique": "Els recomptes no són únics, ja que el seguiment dels subscriptors individuals està desactivat.",
"analytics.title": "Indicadors",
"analytics.toDate": "Fins a",
"bounces.complaint": "Complaint",
"bounces.hard": "Hard",
"bounces.soft": "Soft",
"bounces.source": "Font",
"bounces.unknownService": "Servei desconegut",
"bounces.view": "Veure rebots",
Expand Down Expand Up @@ -208,6 +211,7 @@
"globals.terms.messengers": "Canals",
"globals.terms.minute": "Minut | Minuts",
"globals.terms.month": "Mes | Mesos",
"globals.terms.none": "None",
"globals.terms.second": "Segon | Segons",
"globals.terms.settings": "Configuració",
"globals.terms.subscriber": "Subscriptor | Subscriptors",
Expand Down Expand Up @@ -349,6 +353,7 @@
"settings.appearance.publicName": "Públic",
"settings.bounces.action": "Acció",
"settings.bounces.blocklist": "Llista de bloqueig",
"settings.bounces.complaint": "Complaint",
"settings.bounces.count": "Recompte de rebots",
"settings.bounces.countHelp": "Nombre de rebots per subscriptor",
"settings.bounces.delete": "Esborra",
Expand All @@ -360,11 +365,14 @@
"settings.bounces.enabled": "Activat",
"settings.bounces.folder": "Carpeta",
"settings.bounces.folderHelp": "Nom de la carpeta IMAP a escanejar. Ex: Safata d'entrada.",
"settings.bounces.hard": "Hard",
"settings.bounces.invalidScanInterval": "L'interval d'escaneig ha de ser com a mínim d'1 minut.",
"settings.bounces.name": "Rebots",
"settings.bounces.none": "None",
"settings.bounces.scanInterval": "Interval d'escaneig",
"settings.bounces.scanIntervalHelp": "Interval en què s'hauria d'escanejar la bústia de rebot (s per segon, m per minut).",
"settings.bounces.sendgridKey": "Clau SendGrid ",
"settings.bounces.soft": "Soft",
"settings.bounces.type": "Tipus",
"settings.bounces.username": "Usuari",
"settings.confirmRestart": "Assegura't que les campanyes en curs estiguin en pausa. Reinicia?",
Expand Down
8 changes: 8 additions & 0 deletions i18n/cs-cz.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"analytics.nonUnique": "Protože je sledování odběratelů vypnuté, neexistuje počet na odběratele.",
"analytics.title": "Analytika",
"analytics.toDate": "Do",
"bounces.complaint": "Complaint",
"bounces.hard": "Hard",
"bounces.soft": "Soft",
"bounces.source": "Zdroj",
"bounces.unknownService": "Neznámá služba.",
"bounces.view": "Zobrazit převzetí",
Expand Down Expand Up @@ -208,6 +211,7 @@
"globals.terms.messengers": "Kurýři",
"globals.terms.minute": "Minuta | Minuty",
"globals.terms.month": "Měsíc | Měsíce",
"globals.terms.none": "None",
"globals.terms.second": "Vteřina | Vteřiny",
"globals.terms.settings": "Nastavení",
"globals.terms.subscriber": "Odběratel | Odběratelé",
Expand Down Expand Up @@ -349,6 +353,7 @@
"settings.appearance.publicName": "Veřejné",
"settings.bounces.action": "Akce",
"settings.bounces.blocklist": "Seznam blokovaných",
"settings.bounces.complaint": "Complaint",
"settings.bounces.count": "Počet případů nedoručitelnosti",
"settings.bounces.countHelp": "Počet případů nedoručitelnosti na odběratele",
"settings.bounces.delete": "Odstranit",
Expand All @@ -360,11 +365,14 @@
"settings.bounces.enabled": "Povoleno",
"settings.bounces.folder": "Složka",
"settings.bounces.folderHelp": "Název složky IMAP ke skenování. Např.: Došlá pošta.",
"settings.bounces.hard": "Hard",
"settings.bounces.invalidScanInterval": "Interval skenování v případě nedoručitelnosti by měl být minimálně 1 minuta.",
"settings.bounces.name": "Případy nedoručitelnosti",
"settings.bounces.none": "None",
"settings.bounces.scanInterval": "Interval skenování",
"settings.bounces.scanIntervalHelp": "Interval, ve kterém by se poštovní schránka v případě nedoručitelnosti měla skenovat na nedoručitelnost (s - sekundy, m - minuty).",
"settings.bounces.sendgridKey": "Klíč SendGrid",
"settings.bounces.soft": "Soft",
"settings.bounces.type": "Typ",
"settings.bounces.username": "Jméno uživatele",
"settings.confirmRestart": "Ujistěte se, že jsou běžící kampaně pozastavené. Restartovat?",
Expand Down
8 changes: 8 additions & 0 deletions i18n/cy.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"analytics.nonUnique": "Nid yw'r niferoedd yn unigryw gan fod y system olrhain tanysgrifiwr unigol wedi'i diffodd",
"analytics.title": "Dadansoddeg",
"analytics.toDate": "At",
"bounces.complaint": "Complaint",
"bounces.hard": "Hard",
"bounces.soft": "Soft",
"bounces.source": "Ffynhonnell",
"bounces.unknownService": "Gwasanaeth anhysbys.",
"bounces.view": "Gweld beth sydd wedi sboncio",
Expand Down Expand Up @@ -208,6 +211,7 @@
"globals.terms.messengers": "Negeseuwyr",
"globals.terms.minute": "Munud | Munudau",
"globals.terms.month": "Mis | Misoedd",
"globals.terms.none": "None",
"globals.terms.second": "Eiliad | Eiliadau",
"globals.terms.settings": "Gosodiadau",
"globals.terms.subscriber": "Tanysgrifiwr | Tanysgrifwyr",
Expand Down Expand Up @@ -349,6 +353,7 @@
"settings.appearance.publicName": "Cyhoeddus",
"settings.bounces.action": "Gweithred",
"settings.bounces.blocklist": "Rhestr rwystro",
"settings.bounces.complaint": "Complaint",
"settings.bounces.count": "Nifer y pethau sydd wedi sboncio'n ôl",
"settings.bounces.countHelp": "Nifer y pethau sydd wedi sboncio'n ôl fesul tanysgrifiwr",
"settings.bounces.delete": "Dileu",
Expand All @@ -360,11 +365,14 @@
"settings.bounces.enabled": "Wedi galluogi",
"settings.bounces.folder": "Ffolder",
"settings.bounces.folderHelp": "Enw'r ffolder IMAP i'w sganio. ee: blwch derbyn.",
"settings.bounces.hard": "Hard",
"settings.bounces.invalidScanInterval": "Dylai'r cyfnod sganio ar gyfer negeseuon sydd wedi sboncio'n ôl bara o leiaf 1 munud",
"settings.bounces.name": "Wedi sboncio'n ôl",
"settings.bounces.none": "None",
"settings.bounces.scanInterval": "Cyfnod sganio",
"settings.bounces.scanIntervalHelp": "Y cyfnod ar gyfer sganio'r blwch post ar gyfer negeseuon sydd wedi sboncio'n ôl (e ar gyfer eiliad",
"settings.bounces.sendgridKey": "Allwedd SendGrid",
"settings.bounces.soft": "Soft",
"settings.bounces.type": "Math",
"settings.bounces.username": "Enw defnyddiwr",
"settings.confirmRestart": "Sicrhewch bod yr ymgyrchoedd byw wedi'u rhewi. Ailddechrau?",
Expand Down
Loading

0 comments on commit 5fc28a7

Please sign in to comment.