6db7cc8156
closes https://github.com/TryGhost/Product/issues/4075 - when a member clicks on "Unsubscribe from that list" from Apple Mail, the member's email is put into Mailgun's Unsubscribe suppression list. Ghost listens for "Unsubscribe" events from Mailgun, and unsubscribes the member from all the newsletters - now, the member is only unsubscribed from the newsletter they unsubscribe to (not all of them) - now, the email is also deleted from Mailgun's suppression list, so that it doesn't affect any other membership
163 lines
5.9 KiB
JavaScript
163 lines
5.9 KiB
JavaScript
const moment = require('moment-timezone');
|
|
const logging = require('@tryghost/logging');
|
|
|
|
class EmailEventStorage {
|
|
#db;
|
|
#membersRepository;
|
|
#models;
|
|
#emailSuppressionList;
|
|
|
|
constructor({db, models, membersRepository, emailSuppressionList}) {
|
|
this.#db = db;
|
|
this.#models = models;
|
|
this.#membersRepository = membersRepository;
|
|
this.#emailSuppressionList = emailSuppressionList;
|
|
}
|
|
|
|
async handleDelivered(event) {
|
|
// To properly handle events that are received out of order (this happens because of polling)
|
|
// only set if delivered_at is null
|
|
await this.#db.knex('email_recipients')
|
|
.where('id', '=', event.emailRecipientId)
|
|
.whereNull('delivered_at')
|
|
.update({
|
|
delivered_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
|
});
|
|
}
|
|
|
|
async handleOpened(event) {
|
|
// To properly handle events that are received out of order (this happens because of polling)
|
|
// only set if opened_at is null
|
|
await this.#db.knex('email_recipients')
|
|
.where('id', '=', event.emailRecipientId)
|
|
.whereNull('opened_at')
|
|
.update({
|
|
opened_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
|
});
|
|
}
|
|
|
|
async handlePermanentFailed(event) {
|
|
// To properly handle events that are received out of order (this happens because of polling)
|
|
// only set if failed_at is null
|
|
await this.#db.knex('email_recipients')
|
|
.where('id', '=', event.emailRecipientId)
|
|
.whereNull('failed_at')
|
|
.update({
|
|
failed_at: moment.utc(event.timestamp).format('YYYY-MM-DD HH:mm:ss')
|
|
});
|
|
await this.saveFailure('permanent', event);
|
|
}
|
|
|
|
async handleTemporaryFailed(event) {
|
|
await this.saveFailure('temporary', event);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {'temporary'|'permanent'} severity
|
|
* @param {import('@tryghost/email-events').EmailTemporaryBouncedEvent|import('@tryghost/email-events').EmailBouncedEvent} event
|
|
* @param {{transacting?: any}} options
|
|
* @returns
|
|
*/
|
|
async saveFailure(severity, event, options = {}) {
|
|
if (!event.error) {
|
|
logging.warn(`Missing error information provided for ${severity} failure event with id ${event.id}`);
|
|
return;
|
|
}
|
|
|
|
if (!options || !options.transacting) {
|
|
return await this.#models.EmailRecipientFailure.transaction(async (transacting) => {
|
|
await this.saveFailure(severity, event, {transacting});
|
|
});
|
|
}
|
|
|
|
// Create a forUpdate transaction
|
|
const existing = await this.#models.EmailRecipientFailure.findOne({
|
|
email_recipient_id: event.emailRecipientId
|
|
}, {...options, require: false, forUpdate: true});
|
|
|
|
if (!existing) {
|
|
// Create a new failure
|
|
await this.#models.EmailRecipientFailure.add({
|
|
email_id: event.emailId,
|
|
member_id: event.memberId,
|
|
email_recipient_id: event.emailRecipientId,
|
|
severity,
|
|
message: event.error.message || `Error ${event.error.enhancedCode ?? event.error.code}`,
|
|
code: event.error.code,
|
|
enhanced_code: event.error.enhancedCode,
|
|
failed_at: event.timestamp,
|
|
event_id: event.id
|
|
}, {...options, autoRefresh: false});
|
|
} else {
|
|
if (existing.get('severity') === 'permanent') {
|
|
// Already marked as failed, no need to change anything here
|
|
return;
|
|
}
|
|
|
|
if (existing.get('failed_at') > event.timestamp) {
|
|
/// We can get events out of order, so only save the last one
|
|
return;
|
|
}
|
|
|
|
// Update the existing failure
|
|
await existing.save({
|
|
severity,
|
|
message: event.error.message || `Error ${event.error.enhancedCode ?? event.error.code}`,
|
|
code: event.error.code,
|
|
enhanced_code: event.error.enhancedCode ?? null,
|
|
failed_at: event.timestamp,
|
|
event_id: event.id
|
|
}, {...options, patch: true, autoRefresh: false});
|
|
}
|
|
}
|
|
|
|
async handleUnsubscribed(event) {
|
|
try {
|
|
// Unsubscribe member from the specific newsletter
|
|
const newsletters = await this.findNewslettersToKeep(event);
|
|
await this.#membersRepository.update({newsletters}, {id: event.memberId});
|
|
|
|
// Remove member from Mailgun's suppression list
|
|
await this.#emailSuppressionList.removeUnsubscribe(event.email);
|
|
} catch (err) {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
|
|
async handleComplained(event) {
|
|
try {
|
|
await this.#models.EmailSpamComplaintEvent.add({
|
|
member_id: event.memberId,
|
|
email_id: event.emailId,
|
|
email_address: event.email
|
|
});
|
|
} catch (err) {
|
|
if (err.code !== 'ER_DUP_ENTRY' && err.code !== 'SQLITE_CONSTRAINT') {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async findNewslettersToKeep(event) {
|
|
try {
|
|
const member = await this.#membersRepository.get({email: event.email}, {
|
|
withRelated: ['newsletters']
|
|
});
|
|
const existingNewsletters = member.related('newsletters');
|
|
|
|
const email = await this.#models.Email.findOne({id: event.emailId});
|
|
const newsletterToRemove = email.get('newsletter_id');
|
|
|
|
return existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
|
|
return {id: n.id};
|
|
});
|
|
} catch (err) {
|
|
logging.error(err);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = EmailEventStorage;
|