diff --git a/docs/configuration.md b/docs/configuration.md index 4e1a6dd..daa8c95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,6 +146,8 @@ It saves `site`, `newsletter`, `ai`, `style` and `workflow`, adds `memory` entri `senderName`, `senderEmail` and `replyTo`. `EMAIL_FROM` wins over the sender when it is set; a `postalAddress` here wins over `POSTAL_ADDRESS`. +`perMinute` caps how many newsletter emails go out per minute; unset sends as fast as the provider takes them. A sender still building its reputation does better spreading a big send over half an hour or more (Gmail defers mail that arrives in a burst). + ### `ai` `textModel`, `imageModel`, `videoModel` and `embeddingModel` override the variables of the same names. `knowledgeSources` are pages the AI reads besides the blog itself (an `llms.txt`, docs, a changelog feed). `voice` is the house style every draft follows. diff --git a/packages/server/src/content.ts b/packages/server/src/content.ts index 81f228a..e076b19 100644 --- a/packages/server/src/content.ts +++ b/packages/server/src/content.ts @@ -44,6 +44,12 @@ export interface NewsletterConfig extends NewsletterSettings { * emails use them too (no post slug; the send id is the email's own), plus {sequence} and {step}. */ emailHeaders?: Record | null; + /** + * Most newsletter emails to send per minute (the cron runs every minute). Unset sends as fast as the + * provider takes them. A cap spreads a big send out, which mailbox providers prefer from a sender still + * building its reputation. + */ + perMinute?: number | null; } export async function newsletterSettings(env: Env, db: D1Database): Promise { diff --git a/packages/server/src/newsletter.ts b/packages/server/src/newsletter.ts index 20f583b..bf8f936 100644 --- a/packages/server/src/newsletter.ts +++ b/packages/server/src/newsletter.ts @@ -279,10 +279,13 @@ export async function processSends(env: Env, db: D1Database, options: AppOptions if (!transport) return stats; const deadline = Date.now() + budgetMs; const templates = new Map(); + const { perMinute } = await newsletterSettings(env, db); + // A run handles at most the per-minute cap; the next cron run picks up the rest. + const cap = perMinute && perMinute > 0 ? Math.floor(perMinute) : Infinity; let leased: string | null = null; try { - while (Date.now() < deadline) { + while (Date.now() < deadline && stats.sent + stats.failed < cap) { const send = await db.prepare("SELECT * FROM sends WHERE status IN ('queued','sending') ORDER BY created_at LIMIT 1").first(); if (!send) break; // Claim the send for the next batch; if another worker holds it, leave it to them. @@ -298,7 +301,7 @@ export async function processSends(env: Env, db: D1Database, options: AppOptions .prepare( "SELECT r.member_id, r.email, m.analytics_id FROM send_recipients r LEFT JOIN members m ON m.id = r.member_id WHERE r.send_id = ? AND r.status = 'pending' ORDER BY r.member_id LIMIT ?" ) - .bind(send.id, BATCH) + .bind(send.id, Math.min(BATCH, cap - stats.sent - stats.failed)) .all<{ member_id: string; email: string; analytics_id: string | null }>(); if (!batch.length) { await finishSend(db, send.id);