Inbox Rotation
Quick Access: Scale your outreach volume safely by distributing sending load across multiple email accounts, domains, and IPs.
Overview
Inbox Rotation protects your sender reputation by ensuring no single email account exceeds safe sending limits. Instead of sending 500 emails from one address (high risk), PenguinMails automatically distributes them across 10 addresses (50 emails each, low risk). If an accountβs health score drops, itβs automatically paused while others pick up the slack.
Key Capabilities
-
Rotation Pools: Group accounts by purpose (e.g., βSales Teamβ, βNewsletterβ, βCold Outreachβ)
-
Smart Load Balancing: Distribute volume based on account age, warmup status, and health score
-
Auto-Pause & Recovery: Automatically remove accounts with high bounce rates or low reputation
-
Domain Diversity: Rotate across different domains to prevent domain-wide blacklisting
-
Volume Ramping: Automatically increase limits as accounts mature
-
Unified Analytics: Track performance of the entire pool as a single entity
Level 1: Quick Start Guide
Set Up Your First Rotation Pool
Step 1: Create a Pool
Dashboard β Infrastructure β Rotation Pools β Create New
Pool Configuration:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Pool Name: "SDR Outreach Team" β
β Description: Cold outreach for Q1 Sales β
β β
β Strategy: β
β β Round Robin (Even distribution) β
β β Weighted (Based on health/limit) β
β β Random β
β β
β Daily Limit per Account: [50] emails β
β Min. Delay between sends: [120] seconds β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 2: Add Accounts
Select accounts to include in this rotation.
Available Accounts:
β sarah@company.com (Health: 98%)
β mike@company.com (Health: 95%)
β sales@company.net (Health: 92%)
β hello@company.io (Health: 88%)
[Add to Pool]
Step 3: Assign to Campaign
Link your campaign to the pool instead of a single sender.
Campaign Settings β Sender:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β From: [Rotation Pool: SDR Outreach Team βΌ] β
β β
β Fallback Behavior: β
β If pool capacity reached: β
β β Pause campaign until tomorrow β
β β Continue with warning β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 4: Monitor Health
Pool Dashboard:
Status: Healthy (4/4 Active)
Capacity: 200 emails/day
Used Today: 145 (72%)
Account Performance:
- sarah@company.com: 45 sent | 0 bounces
- mike@company.com: 42 sent | 1 bounce
- sales@company.net: 38 sent | 0 bounces
- hello@company.io: 20 sent | 0 bounces
Level 2: Advanced Configuration
Smart Rotation Logic
Configure how the system selects the next sender.
Weighted by Health Score:
rotation_strategy:
type: "weighted_health"
weights:
health_score: 0.6 # Prioritize healthy accounts
warmup_status: 0.3 # Prioritize fully warmed accounts
usage_today: 0.1 # Deprioritize heavily used accounts
constraints:
min_health_score: 85
max_bounce_rate: 2.5%
Domain Diversity Enforcement:
Ensure consecutive emails donβt come from the same domain to avoid pattern detection.
diversity_rules:
prevent_consecutive_domains: true
max_consecutive_provider: 3 # Don't send >3 Gmails in a row
# Example pattern:
# 1. user@domain-a.com (Google)
# 2. user@domain-b.com (Outlook)
# 3. user@domain-c.com (Zoho)
Auto-Pause & Recovery Rules
Protect accounts from burning out.
safety_rules:
# Pause triggers
triggers:
- if: bounce_rate_24h > 5%
action: pause_account
duration: 24h
notify: admin
- if: spam_complaint_count > 1
action: pause_account
duration: 48h
- if: consecutive_failures > 3
action: pause_account
duration: 1h
# Recovery logic
recovery:
- after: pause_duration
action: enable_warmup_only
duration: 3d
- if: warmup_score > 95
action: restore_to_pool
Volume Ramping
Automatically increase sending limits for new accounts.
{
"ramping_schedule": {
"week_1": { "daily_limit": 10, "min_delay": 300 },
"week_2": { "daily_limit": 20, "min_delay": 240 },
"week_3": { "daily_limit": 35, "min_delay": 180 },
"week_4": { "daily_limit": 50, "min_delay": 120 }
},
"condition": "health_score > 90"
}
Level 3: Technical Implementation
Database Schema
-- Rotation Pools
CREATE TABLE rotation_pools (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
description TEXT,
strategy VARCHAR(50) DEFAULT 'round_robin', -- round_robin, weighted, random
-- Limits
daily_limit_per_account INTEGER DEFAULT 50,
max_pool_capacity INTEGER, -- Total daily emails for pool
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_rotation_pools_tenant ON rotation_pools(tenant_id);
-- Pool Members (Accounts in the pool)
CREATE TABLE pool_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pool_id UUID NOT NULL REFERENCES rotation_pools(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES email_accounts(id),
status VARCHAR(50) DEFAULT 'active', -- active, paused, cooling_down
priority INTEGER DEFAULT 1,
weight DECIMAL(3,2) DEFAULT 1.0,
-- Stats for rotation logic
sent_today INTEGER DEFAULT 0,
last_sent_at TIMESTAMP,
-- Override limits
custom_daily_limit INTEGER,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(pool_id, account_id)
);
CREATE INDEX idx_pool_members_pool ON pool_members(pool_id);
CREATE INDEX idx_pool_members_account ON pool_members(account_id);
-- Rotation Rules (Safety & Logic)
CREATE TABLE rotation_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pool_id UUID NOT NULL REFERENCES rotation_pools(id) ON DELETE CASCADE,
rule_type VARCHAR(50), -- pause_trigger, diversity, ramping
config JSONB NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Account Health History (for scoring)
CREATE TABLE account_health_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES email_accounts(id),
health_score INTEGER, -- 0-100
bounce_rate DECIMAL(5,2),
spam_rate DECIMAL(5,2),
open_rate DECIMAL(5,2),
snapshot_date DATE DEFAULT CURRENT_DATE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_account_health_account ON account_health_logs(account_id);
Rotation Algorithm (TypeScript)
The core logic for selecting the next sender.
class RotationService {
/**
* Select the best account for the next email
*/
async selectNextSender(poolId: string): Promise<EmailAccount> {
// 1. Fetch active members
const members = await db.poolMembers.find({
where: {
pool_id: poolId,
status: 'active'
},
include: ['account']
});
// 2. Filter out ineligible accounts
const eligible = members.filter(member => {
const limit = member.custom_daily_limit || member.pool.daily_limit_per_account;
// Check limits
if (member.sent_today >= limit) return false;
// Check cooldown (min delay)
const minDelay = 120 * 1000; // 2 mins
const timeSinceLast = Date.now() - new Date(member.last_sent_at).getTime();
if (timeSinceLast < minDelay) return false;
return true;
});
if (eligible.length === 0) {
throw new Error('Pool exhausted: No accounts available');
}
// 3. Apply Strategy
const pool = await db.rotationPools.findById(poolId);
if (pool.strategy === 'weighted') {
return this.selectWeighted(eligible);
} else if (pool.strategy === 'random') {
return eligible[Math.floor(Math.random() * eligible.length)].account;
} else {
// Round Robin (default): Select one with least sends today, or longest time since last send
return eligible.sort((a, b) => a.sent_today - b.sent_today)[0].account;
}
}
/**
* Weighted selection based on health and capacity
*/
private selectWeighted(members: PoolMember[]): EmailAccount {
// Calculate score: Health * (Remaining Capacity / Total Capacity)
const scored = members.map(m => {
const health = m.account.health_score || 50;
const limit = m.custom_daily_limit || 50;
const remaining = limit - m.sent_today;
return {
member: m,
score: health * (remaining / limit)
};
});
// Sort by score desc
scored.sort((a, b) => b.score - a.score);
return scored[0].member.account;
}
}
Health Monitoring Service
Runs periodically to update scores and trigger safety rules.
class HealthMonitor {
async checkPoolHealth(poolId: string) {
const members = await db.poolMembers.findByPool(poolId);
for (const member of members) {
const stats = await this.getDailyStats(member.account_id);
// 1. Check Pause Triggers
if (stats.bounceRate > 0.05) { // 5% bounce rate
await this.pauseMember(member, 'High bounce rate detected');
continue;
}
// 2. Update Health Score
const newScore = this.calculateHealthScore(stats);
await db.emailAccounts.update(member.account_id, { health_score: newScore });
// 3. Check Ramping
if (newScore > 90 && member.sent_today >= member.custom_daily_limit) {
// Auto-scale limit if eligible
await this.increaseLimit(member);
}
}
}
private calculateHealthScore(stats: DailyStats): number {
// Simple algorithm: Start at 100, deduct for bad signals
let score = 100;
score -= (stats.bounceRate * 100 * 2); // Heavy penalty for bounces
score -= (stats.spamComplaintRate * 100 * 5); // Severe penalty for spam
score += (stats.openRate > 0.3 ? 5 : 0); // Bonus for good engagement
return Math.max(0, Math.min(100, score));
}
}
API Endpoints
// Create Pool
router.post('/api/rotation/pools', async (req, res) => {
const pool = await db.rotationPools.create({
tenant_id: req.user.tenantId,
...req.body
});
res.json(pool);
});
// Add Members
router.post('/api/rotation/pools/:id/members', async (req, res) => {
const { accountIds } = req.body;
const members = await Promise.all(accountIds.map(accId =>
db.poolMembers.create({
pool_id: req.params.id,
account_id: accId
})
));
res.json(members);
});
// Get Pool Status
router.get('/api/rotation/pools/:id/status', async (req, res) => {
const members = await db.poolMembers.find({
where: { pool_id: req.params.id },
include: ['account']
});
const stats = {
total_accounts: members.length,
active_accounts: members.filter(m => m.status === 'active').length,
total_sent_today: members.reduce((sum, m) => sum + m.sent_today, 0),
total_capacity: members.reduce((sum, m) => sum + (m.custom_daily_limit || 50), 0)
};
res.json({ stats, members });
});
Last Updated: November 25, 2025 Status: Available - Core Feature Target Release: Q1 2026 Owner: Infrastructure Team