Ghost/ghost/email-service/lib/EmailEventStorage.js
Sag 6db7cc8156
Removed email from Mailgun's suppression list on unsubscribe (#18922)
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
2023-11-13 16:56:37 -03:00

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;