Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 33 additions & 90 deletions ghost/core/core/server/services/email-service/DomainWarmingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -81,6 +41,8 @@ export class DomainWarmingService {
this.#emailModel = dependencies.models.Email;
this.#labs = dependencies.labs;
this.#config = dependencies.config;

this.#warmupConfig = DefaultWarmupOptions;
}

/**
Expand All @@ -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<number> {
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<number> {
const result = await this.#emailModel.findPage({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
async #getDaysSinceFirstEmail(): Promise<number> {
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.ceil((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<number> {
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.floor(
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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 () {
Expand Down Expand Up @@ -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++) {
Expand All @@ -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;
Expand Down
Loading
Loading