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));
}
Comment on lines +64 to 76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Day calculation advances within the same day; use floor/clamp.
Using Math.ceil means any positive diff becomes day 1, so a second send minutes later bumps the warmup. That contradicts same‑day stability and can overshoot early.

🛠️ Proposed fix
-        return Math.ceil((Date.now() - new Date(res.data[0].get('created_at') as string).getTime()) / (1000 * 60 * 60 * 24));
+        const diffMs = Date.now() - new Date(res.data[0].get('created_at') as string).getTime();
+        const diffDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
+        return diffDays;
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 64 - 76, The day-count calculation in `#getDaysSinceFirstEmail` uses
Math.ceil which increments the day on any positive difference; change it to use
Math.floor on (Date.now() - new Date(res.data[0].get('created_at') as
string).getTime()) / (1000 * 60 * 60 * 24) and clamp the result to a minimum of
0 (e.g., Math.max(0, Math.floor(...))) so emails sent later the same day do not
advance the warmup day.


/**
* @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)
Comment on lines +83 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Warmup completion and rounding are off by one (and lint).
day > totalDays keeps the ramp active on the completion day, and Math.floor undercuts the expected progression values. Also, ESLint flags missing semicolons here.

🛠️ Proposed fix
-        const day = await this.#getDaysSinceFirstEmail()
-        if (day > this.#warmupConfig.totalDays) {
-            return Infinity
+        const day = await this.#getDaysSinceFirstEmail();
+        if (day >= this.#warmupConfig.totalDays) {
+            return Infinity;
         }

-        const limit = Math.floor(
+        const limit = Math.round(
             this.#warmupConfig.start *
             Math.pow(
                 this.#warmupConfig.end / this.#warmupConfig.start,
                 day / (this.#warmupConfig.totalDays - 1)
             )
-        )
+        );

-        return Math.min(emailCount, limit)
+        return Math.min(emailCount, limit);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
async getWarmupLimit(emailCount: number): Promise<number> {
const day = await this.#getDaysSinceFirstEmail();
if (day >= this.#warmupConfig.totalDays) {
return Infinity;
}
const limit = Math.round(
this.#warmupConfig.start *
Math.pow(
this.#warmupConfig.end / this.#warmupConfig.start,
day / (this.#warmupConfig.totalDays - 1)
)
);
return Math.min(emailCount, limit);
🧰 Tools
🪛 ESLint

[error] 84-85: Missing semicolon.

(semi)


[error] 86-87: Missing semicolon.

(semi)


[error] 95-96: Missing semicolon.

(semi)

🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 83 - 97, In getWarmupLimit, the warmup completion check and rounding are
off: change the completion test from using ">" to ">=" when comparing day to
this.#warmupConfig.totalDays (so the ramp stops on the completion day), switch
Math.floor to Math.round when computing limit so intermediate expected values
aren’t undercut, and add missing semicolons to the function body; update
references in the calculations that use this.#warmupConfig.start,
this.#warmupConfig.end, and this.#warmupConfig.totalDays and keep the final
return as 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