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

Avoid counting partial days as full days.
Math.ceil bumps the day after any positive elapsed time, so a second send later the same day can advance the warmup unexpectedly. Consider floor-based whole-day calculation (and clamp at 0).

🛠️ Suggested fix
-        return Math.ceil((Date.now() - new Date(res.data[0].get('created_at') as string).getTime()) / (1000 * 60 * 60 * 24));
+        const firstSentAt = new Date(res.data[0].get('created_at') as string).getTime();
+        const msPerDay = 1000 * 60 * 60 * 24;
+        const daysSince = Math.floor((Date.now() - firstSentAt) / msPerDay);
+        return Math.max(0, daysSince);
📝 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 #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));
}
async `#getDaysSinceFirstEmail`(): Promise<number> {
const res = await this.#emailModel.findPage({
filter: 'csd_email_count:-null',
order: 'created_at ASC',
limit: 1
});
if (!res.data.length) {
return 0;
}
const firstSentAt = new Date(res.data[0].get('created_at') as string).getTime();
const msPerDay = 1000 * 60 * 60 * 24;
const daysSince = Math.floor((Date.now() - firstSentAt) / msPerDay);
return Math.max(0, daysSince);
}
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 64 - 76, The `#getDaysSinceFirstEmail` function uses Math.ceil which can
count partial days as full days; change the calculation to use Math.floor for
whole-day elapsed calculation and clamp the result to a minimum of 0 (e.g.,
compute diff = Date.now() - new Date(res.data[0].get('created_at') as
string).getTime(), days = Math.floor(diff / (1000*60*60*24)), return Math.max(0,
days)). Ensure you update only the return expression in `#getDaysSinceFirstEmail`
to avoid negative values and prevent advancing warmup on the same calendar 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

End warmup at day >= totalDays (and fix lint semicolons).
With a day index starting at 0, > leaves an extra day and contradicts the day‑42 “Infinity” expectation. ESLint also flags missing semicolons in this block.

🛠️ Suggested 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;
         }
@@
-        )
+        );
 
-        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.floor(
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 of DomainWarmingService, change the
end-of-warmup check to use >= instead of > (i.e., if day >=
this.#warmupConfig.totalDays) so day indices starting at 0 correctly return
Infinity at totalDays, and add missing semicolons in the function block (after
the return Math.min(emailCount, limit) and other statement endings) to satisfy
ESLint; locate references to `#getDaysSinceFirstEmail` and this.#warmupConfig to
update the condition and punctuation.

}
}
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