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)
}
Comment on lines +83 to 98

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 | 🟡 Minor

Missing semicolons flagged by ESLint.

Lines 84, 86, and 95 are missing semicolons, which violates the project's linting rules.

Proposed fix
     async getWarmupLimit(emailCount: number): Promise<number> {
-        const day = await this.#getDaysSinceFirstEmail()
+        const day = await this.#getDaysSinceFirstEmail();
         if (day > this.#warmupConfig.totalDays) {
-            return Infinity
+            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)
+        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)


[error] 97-98: Missing semicolon.

(semi)

🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 83 - 98, The getWarmupLimit method is missing semicolons on several
statements causing ESLint failures; update the function (method getWarmupLimit
and any statements using this.#getDaysSinceFirstEmail() and the const limit
calculation that references this.#warmupConfig) to terminate each statement with
a semicolon (after the const day assignment, after the if block return Infinity
line, and after the return Math.min(emailCount, limit) line) so the file
conforms to the project's semicolon linting rule.

}
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)));
};
Comment on lines +298 to 303

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 | 🟡 Minor

Inconsistent rounding: Math.round vs implementation's Math.floor.

The getExpectedLimit helper uses Math.round, but the actual DomainWarmingService.getWarmupLimit implementation uses Math.floor. This could cause test failures for edge cases where the calculated value has a fractional part near 0.5.

Proposed fix to match implementation
             const getExpectedLimit = (day) => {
                 const start = 200;
                 const end = 200000;
                 const totalDays = 42;
-                return Math.round(start * Math.pow(end / start, day / (totalDays - 1)));
+                return Math.floor(start * Math.pow(end / start, day / (totalDays - 1)));
             };
📝 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
const getExpectedLimit = (day) => {
const start = 200;
const end = 200000;
const totalDays = 42;
return Math.round(start * Math.pow(end / start, day / (totalDays - 1)));
};
const getExpectedLimit = (day) => {
const start = 200;
const end = 200000;
const totalDays = 42;
return Math.floor(start * Math.pow(end / start, day / (totalDays - 1)));
};
🤖 Prompt for AI Agents
In `@ghost/core/test/integration/services/email-service/domain-warming.test.js`
around lines 298 - 303, The test helper getExpectedLimit uses Math.round but
DomainWarmingService.getWarmupLimit uses Math.floor, causing off-by-one
mismatches; update getExpectedLimit to use Math.floor (keep same
start/end/totalDays formula) so the helper mirrors
DomainWarmingService.getWarmupLimit's rounding behavior when calculating
expected limits.


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