From 4d1012b398495e7a909aa72e81b389b5c7bf661f Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Wed, 10 Dec 2025 17:42:14 +0000 Subject: [PATCH] Switched to time-based domain warmup ref [GVA-617](https://linear.app/ghost/issue/GVA-617/add-new-algorithm-for-time-based-warmup-to-ghost) --- .../email-service/DomainWarmingService.ts | 123 +++++------------ .../email-service/domain-warming.test.js | 63 ++++----- .../domain-warming-service.test.ts | 125 ++++++++++++------ 3 files changed, 146 insertions(+), 165 deletions(-) diff --git a/ghost/core/core/server/services/email-service/DomainWarmingService.ts b/ghost/core/core/server/services/email-service/DomainWarmingService.ts index a1318bb43c4..d8628132cd6 100644 --- a/ghost/core/core/server/services/email-service/DomainWarmingService.ts +++ b/ghost/core/core/server/services/email-service/DomainWarmingService.ts @@ -15,63 +15,23 @@ type EmailRecord = { get(field: string): unknown; }; -type WarmupScalingTable = { - base: { - limit: number; - value: number; - }, - thresholds: { - limit: number; - scale: number; - }[]; - highVolume: { - threshold: number; - maxScale: number; - maxAbsoluteIncrease: number; - }; -} +type WarmupVolumeOptions = { + start: number; + end: number; + totalDays: number; +}; -/** - * Configuration for domain warming email volume scaling. - * - * | Volume Range | Multiplier | - * |--------------|--------------------------------------------------| - * | ≤100 (base) | 200 messages | - * | 101 – 1k | 1.25× (conservative early ramp) | - * | 1k – 5k | 1.5× (moderate increase) | - * | 5k – 100k | 1.75× (faster ramp after proving deliverability) | - * | 100k – 400k | 2× | - * | 400k+ | min(1.2×, +75k) cap | - */ -const WARMUP_SCALING_TABLE: WarmupScalingTable = { - base: { - limit: 100, - value: 200 - }, - thresholds: [{ - limit: 1_000, - scale: 1.25 - }, { - limit: 5_000, - scale: 1.5 - }, { - limit: 100_000, - scale: 1.75 - }, { - limit: 400_000, - scale: 2 - }], - highVolume: { - threshold: 400_000, - maxScale: 1.2, - maxAbsoluteIncrease: 75_000 - } +const DefaultWarmupOptions: WarmupVolumeOptions = { + start: 200, + end: 200000, + totalDays: 42 }; export class DomainWarmingService { #emailModel: EmailModel; #labs: LabsService; #config: ConfigService; + #warmupConfig: WarmupVolumeOptions; constructor(dependencies: { models: {Email: EmailModel}; @@ -81,6 +41,8 @@ export class DomainWarmingService { this.#emailModel = dependencies.models.Email; this.#labs = dependencies.labs; this.#config = dependencies.config; + + this.#warmupConfig = DefaultWarmupOptions; } /** @@ -99,58 +61,39 @@ export class DomainWarmingService { return Boolean(fallbackDomain && fallbackAddress); } - /** - * Get the maximum amount of emails that should be sent from the warming sending domain in today's newsletter - * @param emailCount The total number of emails to be sent in this newsletter - * @returns The number of emails that should be sent from the warming sending domain (remaining emails to be sent from fallback domain) - */ - async getWarmupLimit(emailCount: number): Promise { - const lastCount = await this.#getHighestCount(); - - return Math.min(emailCount, this.#getTargetLimit(lastCount)); - } - - /** - * @returns The highest number of messages sent from the CSD in a single email (excluding today) - */ - async #getHighestCount(): Promise { - const result = await this.#emailModel.findPage({ - filter: `created_at:<${new Date().toISOString().split('T')[0]}`, - order: 'csd_email_count DESC', + async #getDaysSinceFirstEmail(): Promise { + const res = await this.#emailModel.findPage({ + filter: 'csd_email_count:-null', + order: 'created_at ASC', limit: 1 }); - if (!result.data.length) { + if (!res.data.length) { return 0; } - const count = result.data[0].get('csd_email_count'); - return count || 0; + return Math.floor((Date.now() - new Date(res.data[0].get('created_at') as string).getTime()) / (1000 * 60 * 60 * 24)); } /** - * @param lastCount Highest number of messages sent from the CSD in a single email - * @returns The limit for sending from the warming sending domain for the next email + * Get the maximum amount of emails that should be sent from the warming sending domain in today's newsletter + * @param emailCount The total number of emails to be sent in this newsletter + * @returns The number of emails that should be sent from the warming sending domain (remaining emails to be sent from fallback domain) */ - #getTargetLimit(lastCount: number): number { - if (lastCount <= WARMUP_SCALING_TABLE.base.limit) { - return WARMUP_SCALING_TABLE.base.value; - } - - // For high volume senders (400k+), cap the increase at 20% or 75k absolute - if (lastCount > WARMUP_SCALING_TABLE.highVolume.threshold) { - const scaledIncrease = Math.ceil(lastCount * WARMUP_SCALING_TABLE.highVolume.maxScale); - const absoluteIncrease = lastCount + WARMUP_SCALING_TABLE.highVolume.maxAbsoluteIncrease; - return Math.min(scaledIncrease, absoluteIncrease); + async getWarmupLimit(emailCount: number): Promise { + const day = await this.#getDaysSinceFirstEmail(); + if (day >= this.#warmupConfig.totalDays) { + return Infinity; } - for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) { - if (lastCount <= threshold.limit) { - return Math.ceil(lastCount * threshold.scale); - } - } + const limit = Math.round( + this.#warmupConfig.start * + Math.pow( + this.#warmupConfig.end / this.#warmupConfig.start, + day / (this.#warmupConfig.totalDays - 1) + ) + ); - // This should not be reached given the thresholds cover all cases up to highVolume.threshold - return Math.ceil(lastCount * WARMUP_SCALING_TABLE.highVolume.maxScale); + return Math.min(emailCount, limit); } } diff --git a/ghost/core/test/integration/services/email-service/domain-warming.test.js b/ghost/core/test/integration/services/email-service/domain-warming.test.js index 0c00707ad8c..5b58b253b00 100644 --- a/ghost/core/test/integration/services/email-service/domain-warming.test.js +++ b/ghost/core/test/integration/services/email-service/domain-warming.test.js @@ -195,15 +195,12 @@ describe('Domain Warming Integration Tests', function () { const email2 = await sendEmail('Test Post Day 2'); const email2Count = email2.get('email_count'); const csdCount2 = email2.get('csd_email_count'); - const expectedLimit = Math.min(email2Count, Math.ceil(csdCount1 * 1.25)); - assert.equal(csdCount2, expectedLimit); + // Time-based warmup: limit = start * (end/start)^(day/(totalDays-1)) + // Day 1: 200 * (200000/200)^(1/41) ≈ 237 + const expectedLimit = Math.min(email2Count, 237); - if (email2Count >= Math.ceil(csdCount1 * 1.25)) { - assert.equal(csdCount2, Math.ceil(csdCount1 * 1.25), 'Limit should increase by 1.25× when enough recipients exist'); - } else { - assert.equal(csdCount2, email2Count, 'Limit should equal total when recipients < limit'); - } + assert.equal(csdCount2, expectedLimit, 'Day 2 should use time-based warmup limit'); const {customDomainCount} = await countRecipientsByDomain(email2.id); assert.equal(customDomainCount, expectedLimit, `Should send ${expectedLimit} emails from custom domain on day 2`); @@ -223,27 +220,30 @@ describe('Domain Warming Integration Tests', function () { it('handles progression through multiple days correctly', async function () { await createMembers(500, 'multi'); - // Day 1: Base limit of 200 (no prior emails) + // Time-based warmup formula: start * (end/start)^(day/(totalDays-1)) + // With start=200, end=200000, totalDays=42 + + // Day 0: Base limit of 200 setDay(0); const email1 = await sendEmail('Test Post Multi Day 1'); const csdCount1 = email1.get('csd_email_count'); - assert.ok(email1.get('email_count') >= 500, 'Day 1: Should have at least 500 recipients'); - assert.equal(csdCount1, 200, 'Day 1: Should use base limit of 200'); + assert.ok(email1.get('email_count') >= 500, 'Day 0: Should have at least 500 recipients'); + assert.equal(csdCount1, 200, 'Day 0: Should use base limit of 200'); - // Day 2: 200 × 1.25 = 250 + // Day 1: 200 * (1000)^(1/41) ≈ 237 setDay(1); const email2 = await sendEmail('Test Post Multi Day 2'); const csdCount2 = email2.get('csd_email_count'); - assert.equal(csdCount2, 250, 'Day 2: Should scale to 250'); + assert.equal(csdCount2, 237, 'Day 1: Should scale to 237'); - // Day 3: 250 × 1.25 = 313 + // Day 2: 200 * (1000)^(2/41) ≈ 280 setDay(2); const email3 = await sendEmail('Test Post Multi Day 3'); const csdCount3 = email3.get('csd_email_count'); - assert.equal(csdCount3, 313, 'Day 3: Should scale to 313'); + assert.equal(csdCount3, 280, 'Day 2: Should scale to 280'); }); it('respects total email count when it is less than warmup limit', async function () { @@ -293,17 +293,13 @@ describe('Domain Warming Integration Tests', function () { let previousCsdCount = 0; - const getExpectedScale = (count) => { - if (count <= 100) { - return 200; - } - if (count <= 1000) { - return Math.ceil(count * 1.25); - } - if (count <= 5000) { - return Math.ceil(count * 1.5); - } - return Math.ceil(count * 1.75); + // Time-based warmup: limit = start * (end/start)^(day/(totalDays-1)) + // With start=200, end=200000, totalDays=42 + const getExpectedLimit = (day) => { + const start = 200; + const end = 200000; + const totalDays = 42; + return Math.round(start * Math.pow(end / start, day / (totalDays - 1))); }; for (let day = 0; day < 5; day++) { @@ -313,19 +309,14 @@ describe('Domain Warming Integration Tests', function () { const csdCount = email.get('csd_email_count'); const totalCount = email.get('email_count'); - assert.ok(csdCount > 0, `Day ${day + 1}: Should send via custom domain`); - assert.ok(csdCount <= totalCount, `Day ${day + 1}: CSD count should not exceed total`); + assert.ok(csdCount > 0, `Day ${day}: Should send via custom domain`); + assert.ok(csdCount <= totalCount, `Day ${day}: CSD count should not exceed total`); + + const expectedLimit = Math.min(totalCount, getExpectedLimit(day)); + assert.equal(csdCount, expectedLimit, `Day ${day}: Should match time-based warmup limit`); if (previousCsdCount > 0) { - assert.ok(csdCount >= previousCsdCount, `Day ${day + 1}: Should not decrease`); - - if (csdCount === totalCount) { - assert.equal(csdCount, totalCount, `Day ${day + 1}: Reached full capacity`); - } else { - const expectedScale = getExpectedScale(previousCsdCount); - assert.ok(csdCount === previousCsdCount || csdCount === expectedScale, - `Day ${day + 1}: Should maintain or scale appropriately (got ${csdCount}, previous ${previousCsdCount}, expected ${expectedScale})`); - } + assert.ok(csdCount >= previousCsdCount, `Day ${day}: Should not decrease from previous day`); } previousCsdCount = csdCount; diff --git a/ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts b/ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts index 7d14d3fa761..8f7c36614c1 100644 --- a/ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts +++ b/ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts @@ -13,6 +13,7 @@ describe('Domain Warming Service', function () { let Email: ReturnType | { findPage: sinon.SinonStub | (() => Promise); }; + let clock: sinon.SinonFakeTimers; beforeEach(function () { labs = { @@ -26,9 +27,13 @@ describe('Domain Warming Service', function () { Email = createModelClass({ findAll: [] }); + + // Fix the current time for consistent test results + clock = sinon.useFakeTimers(new Date('2024-01-15T12:00:00Z').getTime()); }); afterEach(function () { + clock.restore(); sinon.restore(); }); @@ -102,7 +107,14 @@ describe('Domain Warming Service', function () { }); describe('getWarmupLimit', function () { - it('should return 200 when no previous emails exist', async function () { + // Helper to create a date N days ago + function daysAgo(days: number): string { + const date = new Date('2024-01-15T12:00:00Z'); + date.setDate(date.getDate() - days); + return date.toISOString(); + } + + it('should return 200 (start value) when no previous emails exist (day 0)', async function () { Email = createModelClass({ findAll: [] }); @@ -117,10 +129,11 @@ describe('Domain Warming Service', function () { assert.equal(result, 200); }); - it('should return 200 when highest count is 0', async function () { + it('should return 200 (start value) when first email was today (day 0)', async function () { Email = createModelClass({ findAll: [{ - csd_email_count: 0 + csd_email_count: 100, + created_at: daysAgo(0) }] }); @@ -135,9 +148,11 @@ describe('Domain Warming Service', function () { }); it('should return emailCount when it is less than calculated limit', async function () { + // After 21 days (halfway through 42-day warmup), limit should be much higher than 1000 Email = createModelClass({ findAll: [{ - csd_email_count: 1000 + csd_email_count: 100, + created_at: daysAgo(21) }] }); @@ -147,16 +162,17 @@ describe('Domain Warming Service', function () { config }); - // With lastCount=1000, calculated limit is 1250 (1.25× scale) - // emailCount=1000 is less than 1250, so return emailCount + // emailCount=1000 should be less than the calculated limit at day 21 const result = await service.getWarmupLimit(1000); assert.equal(result, 1000); }); it('should return calculated limit when emailCount is greater', async function () { + // Day 1 of warmup: limit should be 237 (200 * (200000/200)^(1/41)) Email = createModelClass({ findAll: [{ - csd_email_count: 1000 + csd_email_count: 100, + created_at: daysAgo(1) }] }); @@ -167,13 +183,15 @@ describe('Domain Warming Service', function () { }); const result = await service.getWarmupLimit(5000); - assert.equal(result, 1250); + // Day 1: 200 * (1000)^(1/41) ≈ 237 + assert.equal(result, 237); }); it('should handle csd_email_count being null', async function () { Email = createModelClass({ findAll: [{ - csd_email_count: null + csd_email_count: null, + created_at: daysAgo(0) }] }); @@ -191,6 +209,7 @@ describe('Domain Warming Service', function () { Email = createModelClass({ findAll: [{ // csd_email_count is undefined + created_at: daysAgo(0) }] }); @@ -204,14 +223,12 @@ describe('Domain Warming Service', function () { assert.equal(result, 200); }); - it('should query for emails created before today', async function () { + it('should query for first email with csd_email_count', async function () { const findPageStub = sinon.stub().resolves({data: []}); Email = { findPage: findPageStub }; - const today = new Date().toISOString().split('T')[0]; - const service = new DomainWarmingService({ models: {Email}, labs, @@ -223,41 +240,33 @@ describe('Domain Warming Service', function () { sinon.assert.calledOnce(findPageStub); const callArgs = findPageStub.firstCall.args[0]; assert.ok(callArgs.filter); - assert.ok(callArgs.filter.includes(`created_at:<${today}`)); - assert.equal(callArgs.order, 'csd_email_count DESC'); + assert.ok(callArgs.filter.includes('csd_email_count:-null')); + assert.equal(callArgs.order, 'created_at ASC'); assert.equal(callArgs.limit, 1); }); - it('should return correct warmup progression through the stages', async function () { - // Test the complete warmup progression - // New conservative scaling: - // - Base: 200 for counts ≤100 - // - 1.25× until 1k (conservative early ramp) - // - 1.5× until 5k (moderate increase) - // - 1.75× until 100k (faster ramp after proving deliverability) - // - 2× until 400k - // - High volume (400k+): min(1.2×, lastCount + 75k) to avoid huge jumps + it('should return correct warmup progression through the days', async function () { + // Test the time-based warmup progression + // Formula: start * (end/start)^(day/(totalDays-1)) + // With start=200, end=200000, totalDays=42 + // This creates exponential growth from 200 to 200000 over 42 days const testCases = [ - {lastCount: 0, expected: 200}, - {lastCount: 50, expected: 200}, - {lastCount: 100, expected: 200}, - {lastCount: 200, expected: 250}, // 200 × 1.25 = 250 - {lastCount: 500, expected: 625}, // 500 × 1.25 = 625 - {lastCount: 1000, expected: 1250}, // 1000 × 1.25 = 1250 - {lastCount: 2000, expected: 3000}, // 2000 × 1.5 = 3000 - {lastCount: 5000, expected: 7500}, // 5000 × 1.5 = 7500 - {lastCount: 50000, expected: 87500}, // 50000 × 1.75 = 87500 - {lastCount: 100000, expected: 175000}, // 100000 × 1.75 = 175000 - {lastCount: 200000, expected: 400000}, // 200000 × 2 = 400000 - {lastCount: 400000, expected: 800000}, // 400000 × 2 = 800000 - {lastCount: 500000, expected: 575000}, // min(500000 × 1.2, 500000 + 75000) = min(600000, 575000) - {lastCount: 800000, expected: 875000} // min(800000 × 1.2, 800000 + 75000) = min(960000, 875000) + {day: 0, expected: 200}, // Day 0: start value + {day: 1, expected: 237}, // Day 1 + {day: 5, expected: 464}, // Day 5 + {day: 10, expected: 1078}, // Day 10 + {day: 20, expected: 5814}, // Day 20 + {day: 21, expected: 6880}, // Day 21 (halfway) + {day: 30, expected: 31344}, // Day 30 + {day: 40, expected: 168989}, // Day 40 + {day: 41, expected: 200000} // Day 41: end value ]; for (const testCase of testCases) { const EmailModel = createModelClass({ findAll: [{ - csd_email_count: testCase.lastCount + csd_email_count: 100, + created_at: daysAgo(testCase.day) }] }); @@ -268,8 +277,46 @@ describe('Domain Warming Service', function () { }); const result = await service.getWarmupLimit(10000000); - assert.equal(result, testCase.expected, `Expected ${testCase.expected} for lastCount ${testCase.lastCount}, but got ${result}`); + assert.equal(result, testCase.expected, `Expected ${testCase.expected} for day ${testCase.day}, but got ${result}`); } }); + + it('should return Infinity after warmup period is complete', async function () { + // After 42 days, warmup is complete + Email = createModelClass({ + findAll: [{ + csd_email_count: 100, + created_at: daysAgo(42) + }] + }); + + const service = new DomainWarmingService({ + models: {Email}, + labs, + config + }); + + const result = await service.getWarmupLimit(1000000); + assert.equal(result, Infinity); + }); + + it('should return Infinity well after warmup period is complete', async function () { + // After 100 days, warmup should definitely be complete + Email = createModelClass({ + findAll: [{ + csd_email_count: 100, + created_at: daysAgo(100) + }] + }); + + const service = new DomainWarmingService({ + models: {Email}, + labs, + config + }); + + const result = await service.getWarmupLimit(1000000); + assert.equal(result, Infinity); + }); }); });