mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-26 12:21:36 +03:00
819d0d884c
fixes https://github.com/TryGhost/Team/issues/2366 refs https://ghost.slack.com/archives/C02G9E68C/p1670232405014209 Probem described in issue. In the old MEGA flow: - The `email_verification_required` check is now repeated inside the job In the new email service flow: - The `email_verification_required` is now checked (didn't happen before) - When generating the email batch recipients, we only include members that were created before the email was created. That way it is impossible to avoid limit checks by inserting new members between creating an email and sending an email. - We don't need to repeat the check inside the job because of the above changes Improved handling of large imports: - When checking `email_verification_required`, we now also check if the import threshold is reached (a new method is introduced in vertificationTrigger specifically for this usage). If it is, we start the verification progress. This is required for long running imports that only check the verification threshold at the very end. - This change increases the concurrency of fastq to 3 (refs https://ghost.slack.com/archives/C02G9E68C/p1670232405014209). So when running a long import, it is now possible to send emails without having to wait for the import. Above change makes sure it is not possible to get around the verification limits. Refactoring: - Removed the need to use `updateVerificationTrigger` by making thresholds getters instead of fixed variables. - Improved awaiting of members import job in regression test
218 lines
8.7 KiB
JavaScript
218 lines
8.7 KiB
JavaScript
const errors = require('@tryghost/errors');
|
|
const DomainEvents = require('@tryghost/domain-events');
|
|
const {MemberCreatedEvent} = require('@tryghost/member-events');
|
|
|
|
const messages = {
|
|
emailVerificationNeeded: `We're hard at work processing your import. To make sure you get great deliverability, we'll need to enable some extra features for your account. A member of our team will be in touch with you by email to review your account make sure everything is configured correctly so you're ready to go.`,
|
|
emailVerificationEmailSubject: `Email needs verification`,
|
|
emailVerificationEmailMessageImport: `Email verification needed for site: {siteUrl}, has imported: {amountTriggered} members in the last 30 days.`,
|
|
emailVerificationEmailMessageAdmin: `Email verification needed for site: {siteUrl} has added: {amountTriggered} members through the Admin client in the last 30 days.`,
|
|
emailVerificationEmailMessageAPI: `Email verification needed for site: {siteUrl} has added: {amountTriggered} members through the API in the last 30 days.`
|
|
};
|
|
|
|
class VerificationTrigger {
|
|
/**
|
|
*
|
|
* @param {object} deps
|
|
* @param {() => number} deps.getApiTriggerThreshold Threshold for triggering API&Import sourced verifications
|
|
* @param {() => number} deps.getAdminTriggerThreshold Threshold for triggering Admin sourced verifications
|
|
* @param {() => number} deps.getImportTriggerThreshold Threshold for triggering Import sourced verifications
|
|
* @param {() => boolean} deps.isVerified Check Ghost config to see if we are already verified
|
|
* @param {() => boolean} deps.isVerificationRequired Check Ghost settings to see whether verification has been requested
|
|
* @param {(content: {subject: string, message: string, amountTriggered: number}) => Promise<void>} deps.sendVerificationEmail Sends an email to the escalation address to confirm that customer needs to be verified
|
|
* @param {any} deps.membersStats MemberStats service
|
|
* @param {any} deps.Settings Ghost Settings model
|
|
* @param {any} deps.eventRepository For querying events
|
|
*/
|
|
constructor({
|
|
getApiTriggerThreshold,
|
|
getAdminTriggerThreshold,
|
|
getImportTriggerThreshold,
|
|
isVerified,
|
|
isVerificationRequired,
|
|
sendVerificationEmail,
|
|
membersStats,
|
|
Settings,
|
|
eventRepository
|
|
}) {
|
|
this._getApiTriggerThreshold = getApiTriggerThreshold;
|
|
this._getAdminTriggerThreshold = getAdminTriggerThreshold;
|
|
this._getImportTriggerThreshold = getImportTriggerThreshold;
|
|
this._isVerified = isVerified;
|
|
this._isVerificationRequired = isVerificationRequired;
|
|
this._sendVerificationEmail = sendVerificationEmail;
|
|
this._membersStats = membersStats;
|
|
this._Settings = Settings;
|
|
this._eventRepository = eventRepository;
|
|
|
|
this._handleMemberCreatedEvent = this._handleMemberCreatedEvent.bind(this);
|
|
DomainEvents.subscribe(MemberCreatedEvent, this._handleMemberCreatedEvent);
|
|
}
|
|
|
|
get _apiTriggerThreshold() {
|
|
return this._getApiTriggerThreshold();
|
|
}
|
|
|
|
get _adminTriggerThreshold() {
|
|
return this._getAdminTriggerThreshold();
|
|
}
|
|
|
|
get _importTriggerThreshold() {
|
|
return this._getImportTriggerThreshold();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {MemberCreatedEvent} event
|
|
*/
|
|
async _handleMemberCreatedEvent(event) {
|
|
const source = event.data?.source;
|
|
let sourceThreshold;
|
|
|
|
if (source === 'api') {
|
|
sourceThreshold = this._apiTriggerThreshold;
|
|
} else if (source === 'admin') {
|
|
sourceThreshold = this._adminTriggerThreshold;
|
|
}
|
|
|
|
if (['api', 'admin'].includes(source) && isFinite(sourceThreshold)) {
|
|
const createdAt = new Date();
|
|
createdAt.setDate(createdAt.getDate() - 30);
|
|
const events = await this._eventRepository.getSignupEvents({}, {
|
|
source: source,
|
|
created_at: {
|
|
$gt: createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
|
}
|
|
});
|
|
|
|
if (events.meta.pagination.total > sourceThreshold) {
|
|
await this._startVerificationProcess({
|
|
amount: events.meta.pagination.total,
|
|
throwOnTrigger: false,
|
|
source: source
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async getImportThreshold() {
|
|
const volumeThreshold = this._importTriggerThreshold;
|
|
if (isFinite(volumeThreshold)) {
|
|
const membersTotal = await this._membersStats.getTotalMembers();
|
|
return Math.max(membersTotal, volumeThreshold);
|
|
} else {
|
|
return volumeThreshold;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns false if email verification is required to send an email. It also updates the verification check and might activate email verification.
|
|
* Use this when sending emails.
|
|
*/
|
|
async checkVerificationRequired() {
|
|
// Check if import threshold is reached (could happen that a long import is in progress and we didn't check the threshold yet)
|
|
await this.testImportThreshold();
|
|
return this._isVerificationRequired() && !this._isVerified();
|
|
}
|
|
|
|
async testImportThreshold() {
|
|
if (!isFinite(this._importTriggerThreshold)) {
|
|
// Infinite threshold, quick path
|
|
return;
|
|
}
|
|
|
|
if (this._isVerified()) {
|
|
// Already verified, no need to check limits
|
|
return;
|
|
}
|
|
|
|
if (this._isVerificationRequired()) {
|
|
// Already requested verification, no need to calculate again
|
|
return;
|
|
}
|
|
|
|
const createdAt = new Date();
|
|
createdAt.setDate(createdAt.getDate() - 30);
|
|
const events = await this._eventRepository.getSignupEvents({}, {
|
|
source: 'import',
|
|
created_at: {
|
|
$gt: createdAt.toISOString().replace('T', ' ').substring(0, 19)
|
|
}
|
|
});
|
|
|
|
const membersTotal = await this._membersStats.getTotalMembers();
|
|
|
|
// Import threshold is either the total number of members (discounting any created by imports in
|
|
// the last 30 days) or the threshold defined in config, whichever is greater.
|
|
const importThreshold = Math.max(membersTotal - events.meta.pagination.total, this._importTriggerThreshold);
|
|
if (isFinite(importThreshold) && events.meta.pagination.total > importThreshold) {
|
|
await this._startVerificationProcess({
|
|
amount: events.meta.pagination.total,
|
|
throwOnTrigger: false,
|
|
source: 'import'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef IVerificationResult
|
|
* @property {boolean} needsVerification Whether the verification workflow was triggered
|
|
*/
|
|
|
|
/**
|
|
*
|
|
* @param {object} config
|
|
* @param {number} config.amount The amount of members that triggered the verification process
|
|
* @param {boolean} config.throwOnTrigger Whether to throw if verification is needed
|
|
* @param {string} config.source Source of the verification trigger - currently either 'api' or 'import'
|
|
* @returns {Promise<IVerificationResult>} Object containing property "needsVerification" - true when triggered
|
|
*/
|
|
async _startVerificationProcess({
|
|
amount,
|
|
throwOnTrigger,
|
|
source
|
|
}) {
|
|
if (!this._isVerified()) {
|
|
// Only trigger flag change and escalation email the first time
|
|
if (!this._isVerificationRequired()) {
|
|
await this._Settings.edit([{
|
|
key: 'email_verification_required',
|
|
value: true
|
|
}], {context: {internal: true}});
|
|
|
|
// Setting import as a default message
|
|
let verificationMessage = messages.emailVerificationEmailMessageImport;
|
|
|
|
if (source === 'api') {
|
|
verificationMessage = messages.emailVerificationEmailMessageAPI;
|
|
} else if (source === 'admin') {
|
|
verificationMessage = messages.emailVerificationEmailMessageAdmin;
|
|
}
|
|
|
|
await this._sendVerificationEmail({
|
|
message: verificationMessage,
|
|
subject: messages.emailVerificationEmailSubject,
|
|
amountTriggered: amount
|
|
});
|
|
|
|
if (throwOnTrigger) {
|
|
throw new errors.HostLimitError({
|
|
message: messages.emailVerificationNeeded,
|
|
code: 'EMAIL_VERIFICATION_NEEDED'
|
|
});
|
|
}
|
|
|
|
return {
|
|
needsVerification: true
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
needsVerification: false
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = VerificationTrigger;
|