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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type LabsService = {
};

type EmailModel = {
findOne: (options: {filter: string; order: string}) => Promise<EmailRecord | null>;
findPage: (options: {filter: string; order: string; limit: number}) => Promise<{data: EmailRecord[]}>;
};

type EmailRecord = {
Expand All @@ -20,22 +20,48 @@ type WarmupScalingTable = {
limit: number;
scale: number;
}[];
defaultScale: number;
highVolume: {
threshold: number;
maxScale: number;
maxAbsoluteIncrease: 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: 2
scale: 1.75
}, {
limit: 400_000,
scale: 1.5
scale: 2
}],
defaultScale: 1.25
highVolume: {
threshold: 400_000,
maxScale: 1.2,
maxAbsoluteIncrease: 75_000
}
};

export class DomainWarmingService {
Expand Down Expand Up @@ -72,17 +98,18 @@ export class DomainWarmingService {
* @returns The highest number of messages sent from the CSD in a single email (excluding today)
*/
async #getHighestCount(): Promise<number> {
const email = await this.#emailModel.findOne({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC'
const result = await this.#emailModel.findPage({
filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});

if (!email) {
if (!result.data.length) {
return 0;
}

const count = email.get('csd_email_count');
return count || 0;
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
Comment on lines 100 to 113
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Filter may include today's emails contrary to the documented intent.

The comment on line 98 states "excluding today", but the filter on line 102 uses <= which would include emails created today. If the intent is to exclude today's sends when calculating the next limit, the filter should use < instead.

Proposed fix
     async `#getHighestCount`(): Promise<number> {
         const result = await this.#emailModel.findPage({
-            filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
+            filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
             order: 'csd_email_count DESC',
             limit: 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
async #getHighestCount(): Promise<number> {
const email = await this.#emailModel.findOne({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC'
const result = await this.#emailModel.findPage({
filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});
if (!email) {
if (!result.data.length) {
return 0;
}
const count = email.get('csd_email_count');
return count || 0;
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
async `#getHighestCount`(): Promise<number> {
const result = await this.#emailModel.findPage({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});
if (!result.data.length) {
return 0;
}
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 100 - 113, The method `#getHighestCount` currently builds a filter for
`#emailModel.findPage` using "<=YYYY-MM-DD", which includes today's emails; change
the filter operator to "<" so it excludes today as intended (keep the same date
expression new Date().toISOString().split('T')[0] and the rest of the findPage
options and return logic intact).


/**
Expand All @@ -94,12 +121,20 @@ export class DomainWarmingService {
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);
}

for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
if (lastCount <= threshold.limit) {
if (lastCount < threshold.limit) {
return Math.ceil(lastCount * threshold.scale);
}
}
Comment on lines 131 to 135
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Boundary condition bug: threshold comparison should use <= not <.

The < comparison causes boundary values to fall into the wrong tier. For example, with lastCount = 1000:

  • Current: 1000 < 1000 is false → falls through to 1.5× tier → returns 1500
  • Expected per docs and tests: 1000 should use 1.25× → returns 1250

The same issue affects all boundary values (1000, 5000, 100000, 400000).

Additionally, sort() mutates the array in-place on every call. Consider sorting once at initialization or using toSorted().

Proposed fix
-        for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
-            if (lastCount < threshold.limit) {
+        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);
             }
         }
📝 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
for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
if (lastCount <= threshold.limit) {
if (lastCount < threshold.limit) {
return Math.ceil(lastCount * threshold.scale);
}
}
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);
}
}
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 131 - 135, The loop in DomainWarmingService that iterates
WARMUP_SCALING_TABLE.thresholds uses a strict `<` comparison which misclassifies
boundary values (e.g., lastCount === threshold.limit) and also calls sort() on
the thresholds on every invocation; change the comparison from `<` to `<=` so
boundary values map to the intended tier (affecting the branch that returns
Math.ceil(lastCount * threshold.scale)), and avoid mutating the original array
each call by pre-sorting thresholds once (e.g., sort
WARMUP_SCALING_TABLE.thresholds at initialization or use a non-mutating
toSorted() result) so the function uses an already-sorted thresholds list
instead of sorting in-place every time.


return Math.ceil(lastCount * WARMUP_SCALING_TABLE.defaultScale);
// This should not be reached given the thresholds cover all cases up to highVolume.threshold
return Math.ceil(lastCount * WARMUP_SCALING_TABLE.highVolume.maxScale);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,20 @@ describe('Domain Warming Integration Tests', function () {
}

// Helper: Set fake time to specific day
// Uses a fixed base date to ensure consistent day progression
const baseDate = new Date();
baseDate.setHours(12, 0, 0, 0);

function setDay(daysFromNow = 0) {
if (clock) {
clock.restore();
}
const time = new Date();
const time = new Date(baseDate.getTime());
time.setDate(time.getDate() + daysFromNow);
time.setHours(12, 0, 0, 0);
clock = sinon.useFakeTimers(time.getTime());
clock = sinon.useFakeTimers({
now: time.getTime(),
shouldAdvanceTime: true
});
}

// Helper: Count recipients by domain type
Expand Down Expand Up @@ -183,13 +189,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, csdCount1 * 2);
const expectedLimit = Math.min(email2Count, Math.ceil(csdCount1 * 1.25));

assert.equal(csdCount2, expectedLimit);

// Verify doubling behavior
if (email2Count >= csdCount1 * 2) {
assert.equal(csdCount2, csdCount1 * 2, 'Limit should double when enough recipients exist');
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');
}
Expand All @@ -212,28 +217,27 @@ describe('Domain Warming Integration Tests', function () {
it('handles progression through multiple days correctly', async function () {
await createMembers(500, 'multi');

setDay(0); // Day 1
// Day 1: Base limit of 200 (no prior emails)
setDay(0);
const email1 = await sendEmail('Test Post Multi Day 1');
const csdCount1 = email1.get('csd_email_count');

assert.ok(csdCount1 > 0, 'Day 1: Should send some via custom domain');
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');

setDay(1); // Day 2
// Day 2: 200 × 1.25 = 250
setDay(1);
const email2 = await sendEmail('Test Post Multi Day 2');
const csdCount2 = email2.get('csd_email_count');

assert.ok(csdCount2 > 0, 'Day 2: Should send some via custom domain');
assert.equal(csdCount2, csdCount1 * 2, `Day 2: Should double (got ${csdCount2}, expected ${csdCount1 * 2})`);
assert.equal(csdCount2, 250, 'Day 2: Should scale to 250');

setDay(2); // Day 3
// Day 3: 250 × 1.25 = 313
setDay(2);
const email3 = await sendEmail('Test Post Multi Day 3');
const csdCount3 = email3.get('csd_email_count');

assert.ok(csdCount3 > 0, 'Day 3: Should send some via custom domain');
assert.ok(csdCount3 >= csdCount2, 'Day 3: Should be >= day 2');
assert.ok(csdCount3 === csdCount2 || csdCount3 === csdCount2 * 2 || csdCount3 === email3.get('email_count'),
`Day 3: Should be same, doubled, or total (got ${csdCount3})`);
assert.equal(csdCount3, 313, 'Day 3: Should scale to 313');
});

it('respects total email count when it is less than warmup limit', async function () {
Expand Down Expand Up @@ -283,6 +287,19 @@ 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);
};

for (let day = 0; day < 5; day++) {
setDay(day);

Expand All @@ -299,8 +316,9 @@ describe('Domain Warming Integration Tests', function () {
if (csdCount === totalCount) {
assert.equal(csdCount, totalCount, `Day ${day + 1}: Reached full capacity`);
} else {
assert.ok(csdCount === previousCsdCount || csdCount === previousCsdCount * 2,
`Day ${day + 1}: Should maintain or double (got ${csdCount}, previous ${previousCsdCount})`);
const expectedScale = getExpectedScale(previousCsdCount);
assert.ok(csdCount === previousCsdCount || csdCount === expectedScale,
`Day ${day + 1}: Should maintain or scale appropriately (got ${csdCount}, previous ${previousCsdCount}, expected ${expectedScale})`);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Domain Warming Service', function () {
isSet: sinon.SinonStub;
};
let Email: ReturnType<typeof createModelClass> | {
findOne: sinon.SinonStub | (() => Promise<any>);
findPage: sinon.SinonStub | (() => Promise<any>);
};

beforeEach(function () {
Expand All @@ -17,7 +17,7 @@ describe('Domain Warming Service', function () {
};

Email = createModelClass({
findOne: null
findAll: []
});
});

Expand Down Expand Up @@ -65,9 +65,9 @@ describe('Domain Warming Service', function () {

describe('getWarmupLimit', function () {
it('should return 200 when no previous emails exist', async function () {
Email = {
findOne: async () => null
};
Email = createModelClass({
findAll: []
});

const service = new DomainWarmingService({
models: {Email},
Expand All @@ -80,9 +80,9 @@ describe('Domain Warming Service', function () {

it('should return 200 when highest count is 0', async function () {
Email = createModelClass({
findOne: {
findAll: [{
csd_email_count: 0
}
}]
});

const service = new DomainWarmingService({
Expand All @@ -96,25 +96,27 @@ describe('Domain Warming Service', function () {

it('should return emailCount when it is less than calculated limit', async function () {
Email = createModelClass({
findOne: {
findAll: [{
csd_email_count: 1000
}
}]
});

const service = new DomainWarmingService({
models: {Email},
labs
});

const result = await service.getWarmupLimit(1500);
assert.equal(result, 1500);
// With lastCount=1000, calculated limit is 1250 (1.25× scale)
// emailCount=1000 is less than 1250, so return emailCount
const result = await service.getWarmupLimit(1000);
assert.equal(result, 1000);
});

it('should return calculated limit when emailCount is greater', async function () {
Email = createModelClass({
findOne: {
findAll: [{
csd_email_count: 1000
}
}]
});

const service = new DomainWarmingService({
Expand All @@ -123,14 +125,14 @@ describe('Domain Warming Service', function () {
});

const result = await service.getWarmupLimit(5000);
assert.equal(result, 2000);
assert.equal(result, 1250);
});

it('should handle csd_email_count being null', async function () {
Email = createModelClass({
findOne: {
findAll: [{
csd_email_count: null
}
}]
});

const service = new DomainWarmingService({
Expand All @@ -144,9 +146,9 @@ describe('Domain Warming Service', function () {

it('should handle csd_email_count being undefined', async function () {
Email = createModelClass({
findOne: {
findAll: [{
// csd_email_count is undefined
}
}]
});

const service = new DomainWarmingService({
Expand All @@ -159,9 +161,9 @@ describe('Domain Warming Service', function () {
});

it('should query for emails created before today', async function () {
const findOneStub = sinon.stub().resolves(null);
const findPageStub = sinon.stub().resolves({data: []});
Email = {
findOne: findOneStub
findPage: findPageStub
};

const today = new Date().toISOString().split('T')[0];
Expand All @@ -173,35 +175,45 @@ describe('Domain Warming Service', function () {

await service.getWarmupLimit(1000);

sinon.assert.calledOnce(findOneStub);
const callArgs = findOneStub.firstCall.args[0];
sinon.assert.calledOnce(findPageStub);
const callArgs = findPageStub.firstCall.args[0];
assert.ok(callArgs.filter);
assert.ok(callArgs.filter.includes(`created_at:<${today}`));
assert.equal(callArgs.order, 'csd_email_count DESC');
assert.equal(callArgs.limit, 1);
});

it('should return correct warmup progression through the stages', async function () {
// Test the complete warmup progression
// New conservative scaling:
// - Base: 200 for counts ≤100
// - 1.25× until 1k (conservative early ramp)
// - 1.5× until 5k (moderate increase)
// - 1.75× until 100k (faster ramp after proving deliverability)
// - 2× until 400k
// - High volume (400k+): min(1.2×, lastCount + 75k) to avoid huge jumps
const testCases = [
{lastCount: 0, expected: 200},
{lastCount: 50, expected: 200},
{lastCount: 100, expected: 200},
{lastCount: 200, expected: 400},
{lastCount: 500, expected: 1000},
{lastCount: 1000, expected: 2000},
{lastCount: 50000, expected: 100000},
{lastCount: 100000, expected: 200000},
{lastCount: 200000, expected: 300000},
{lastCount: 400000, expected: 600000},
{lastCount: 500000, expected: 625000},
{lastCount: 800000, expected: 1000000}
{lastCount: 200, expected: 250}, // 200 × 1.25 = 250
{lastCount: 500, expected: 625}, // 500 × 1.25 = 625
{lastCount: 1000, expected: 1250}, // 1000 × 1.25 = 1250
{lastCount: 2000, expected: 3000}, // 2000 × 1.5 = 3000
{lastCount: 5000, expected: 7500}, // 5000 × 1.5 = 7500
{lastCount: 50000, expected: 87500}, // 50000 × 1.75 = 87500
{lastCount: 100000, expected: 175000}, // 100000 × 1.75 = 175000
{lastCount: 200000, expected: 400000}, // 200000 × 2 = 400000
{lastCount: 400000, expected: 800000}, // 400000 × 2 = 800000
{lastCount: 500000, expected: 575000}, // min(500000 × 1.2, 500000 + 75000) = min(600000, 575000)
{lastCount: 800000, expected: 875000} // min(800000 × 1.2, 800000 + 75000) = min(960000, 875000)
];

for (const testCase of testCases) {
const EmailModel = createModelClass({
findOne: {
findAll: [{
csd_email_count: testCase.lastCount
}
}]
});

const service = new DomainWarmingService({
Expand Down