Skip to content
Merged
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.floor((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
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find Email model definition and check created_at field type
find . -type f \( -name "*.ts" -o -name "*.js" \) | xargs rg -l "class Email|interface Email|export.*Email" | head -20

Repository: TryGhost/Ghost

Length of output: 1210


🏁 Script executed:

# Search for Email model in common locations
rg -nP --type=ts -C5 "class Email" --max-count=5

Repository: TryGhost/Ghost

Length of output: 4088


🏁 Script executed:

# Look for Email model definition or interface
rg -nP --type=ts "created_at" -B2 -A2 | grep -A5 -B5 "Email\|model" | head -60

Repository: TryGhost/Ghost

Length of output: 4957


🏁 Script executed:

# Search for Email model or EmailRepository with Bookshelf
rg -nP --type=ts -C3 "class.*Email.*extends|EmailRepository|Email.*model" | head -80

Repository: TryGhost/Ghost

Length of output: 1202


🏁 Script executed:

# Look for Email model in core/server directory specifically
find ./ghost/core/core/server -type f -name "*mail*" -o -name "*email*" | grep -i "model\|repository" | head -20

Repository: TryGhost/Ghost

Length of output: 429


🏁 Script executed:

# Search for where this.#emailModel is defined in DomainWarmingService
rg -nP --type=ts "emailModel\|Email.*Model" ./ghost/core/core/server/services/email-service/ -B3 -A3

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Read the Email model definition
cat -n ./ghost/core/core/server/models/email.js | head -100

Repository: TryGhost/Ghost

Length of output: 3328


🏁 Script executed:

# Read the DomainWarmingService file to understand the context
cat -n ./ghost/core/core/server/services/email-service/DomainWarmingService.ts | head -100

Repository: TryGhost/Ghost

Length of output: 3600


🏁 Script executed:

# Check what Bookshelf's get method returns for timestamps
rg -nP --type=ts "get\(" ./ghost/core/core/server/services -A1 -B1 | grep -A2 -B2 "created_at\|Date\|timestamp" | head -40

Repository: TryGhost/Ghost

Length of output: 2670


🏁 Script executed:

# Search for how other services handle created_at from Bookshelf models
rg -nP --type=ts "\.get\('created_at'\)" -B2 -A2 | head -60

Repository: TryGhost/Ghost

Length of output: 2822


Change type assertion from as string to as Date to match codebase conventions.

The created_at field from Bookshelf models is consistently cast as Date across similar repositories in the codebase (e.g., BookshelfClickEventRepository, BookshelfRecommendationRepository, BookshelfSubscribeEventRepository). Line 75 should use as Date instead of as string for consistency and correctness.

🤖 Prompt for AI Agents
In ghost/core/core/server/services/email-service/DomainWarmingService.ts around
lines 64 to 76, the created_at value is being asserted as a string then passed
to new Date; update the assertion to match codebase conventions by casting the
Bookshelf field to Date (as Date) and use its getTime() directly (e.g.
(res.data[0].get('created_at') as Date).getTime()) so the type reflects the
model and avoids reconstructing a Date from a string.


/**
* @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.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);
}
}
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