Sending email at scale
Anyone can call email_send once. Sending 50, 200, 1000 emails
per tenant per day without burning the tenant’s domain reputation
is a different problem — and most of it sits in your app’s
guardrails, not in the platform.
The non-obvious cost. When you send 50 cold emails through a tenant’s primary M365 mailbox and the recipients mark them as spam, you don’t just damage that user’s deliverability — you damage the whole domain. Their CFO’s invoices start landing in spam too. Apps that don’t guard against this lose tenants.
What you’re protecting
Section titled “What you’re protecting”- Domain reputation — measured by ISPs (Microsoft, Google, Yahoo) based on spam-complaint rate, open rate, bounce rate over the sending domain. Recovery from a damaged reputation takes weeks.
- Send-tier limits — M365 has a hard 10k/day per mailbox, but the soft limit before throttling kicks in is much lower (often ~200/day for new mailboxes, 30/day for warmed-down ones).
- Tenant trust — a user notices when their normal “send mail” suddenly fails because your app exhausted the day’s quota.
- Cross-app fairness — if two apps send aggressively, neither should be able to starve the other.
Canonical guards
Section titled “Canonical guards”A send-at-scale app has three layers of protection. Implement all three.
Layer 1: per-user daily cap
Section titled “Layer 1: per-user daily cap”DAILY_CAP_PER_USER = 25
async def can_send_today(ctx, user_id: str) -> bool: today = date.today().isoformat() key = f"send_count/{user_id}/{today}" count = (await ctx.kv.get(key)) or 0 return count < DAILY_CAP_PER_USER
async def record_send(ctx, user_id: str) -> None: today = date.today().isoformat() key = f"send_count/{user_id}/{today}" count = (await ctx.kv.get(key)) or 0 await ctx.kv.set(key, count + 1, ttl_seconds=86_400 * 2)
async def send_with_guard(ctx, user_id, to_email, subject, body): if not await can_send_today(ctx, user_id): # Defer — your cadence will retry tomorrow return {"deferred": True, "reason": "daily_cap_reached"} await ctx.tools.call( "email_send", to=[to_email], # email_send.to is an array, even for one recipient subject=subject, body=body, ) await record_send(ctx, user_id) return {"sent": True}Defaults to start with:
- 20-25 sends/user/day for outbound to new recipients
- No cap for replies (
email_reply) — those are conversation responses, not outbound
Make this configurable per tenant via install_settings —
some tenants have warmed sending infrastructure and can go higher.
Layer 2: pacing within the day
Section titled “Layer 2: pacing within the day”Don’t release the cap all at once at midnight. Spread sends across business hours — both for fingerprint reasons and because a burst of 25 sends at 09:00 looks more like an automated tool than a human-driven sequence.
HOURLY_RATE = 3 # max 3 sends/hour during business hours
async def can_send_now(ctx, user_id) -> bool: if not await can_send_today(ctx, user_id): return False hour_key = f"send_count_hour/{user_id}/{now().strftime('%Y%m%d-%H')}" hour_count = (await ctx.kv.get(hour_key)) or 0 return hour_count < HOURLY_RATECombined with heartbeats firing hourly, this gives you natural pacing without explicit sleep timers.
Layer 3: human approval for risk-class outbound
Section titled “Layer 3: human approval for risk-class outbound”Some outbound is reversible (template follow-up #2). Some isn’t (a
quote, a meeting acceptance, a contract link). Don’t auto-send the
latter — draft it as an app_records entry with
status="pending_approval", surface it in your app’s UI (or via
ctx.commitments.add), and only call email_send after the user
confirms.
import uuid
if is_risky(touch): draft_id = str(uuid.uuid4()) await ctx.kv.set(f"outbound_draft:{draft_id}", { "id": draft_id, "to": lead_email, "subject": subject, "body": body, "campaign_id": campaign_id, "status": "pending_approval", }) await ctx.commitments.add( f"Approve outbound to {lead_email}", due_in_hours=24, ) return # don't send yet# Otherwise: cap + send as normalWhen the user approves through your app UI, your handler reads the
draft (ctx.kv.get(f"outbound_draft:{draft_id}")), calls email_send,
and updates the row’s status to sent. False-positives in your
“is this risky?” classifier just become one extra click for the user.
The platform tools
email_send/email_replydon’t have a built-inrequire_approvalparameter today. Approval is your app’s responsibility, surfaced via the Commitments Inbox.
Manifest declaration
Section titled “Manifest declaration”required_scopes: - mail.send
install_settings: - key: DAILY_CAP_PER_USER label: "Max emails per user per day" type: integer default: 25 help: "Hard cap on automated outbound. Replies don't count." - key: BUSINESS_HOURS label: "Sending window (tenant-local)" type: string default: "08:00-18:00"Document the cap in your app’s marketplace listing so tenants know what they’re getting.
❌ Anti-patterns
Section titled “❌ Anti-patterns”Don’t: send from a sleep loop with no cap
Section titled “Don’t: send from a sleep loop with no cap”# ❌ DON'T do thisfor lead in leads: await ctx.tools.call("email_send", to=lead.email, ...) await asyncio.sleep(60)You’ve handed an unbounded send rate to the LLM that built the
leads list. One mistake in lead-list filtering → 5000 sends
in one afternoon → domain burned for months. The cap belongs in
your code, not in trust of upstream filtering.
Don’t: cache the “last send time” only in memory
Section titled “Don’t: cache the “last send time” only in memory”# ❌ DON'T do this_last_send_time = {} # module-level global
async def send_paced(user_id, ...): if _last_send_time.get(user_id) > now() - 60: return _last_send_time[user_id] = now() await ctx.tools.call("email_send", ...)The partner-app container can be recycled, scaled out, or moved
to another worker between requests. Two concurrent containers
each have their own dict — the cap is silently doubled. Use
ctx.kv (or app_records for audit) — survives restarts, shared
across workers.
Don’t: pretend bounces don’t matter
Section titled “Don’t: pretend bounces don’t matter”If 5% of your sends bounce (invalid addresses, mailbox-full, recipient blocked), the sending mailbox’s reputation drops fast. Either:
- Pre-verify with a third-party tool (BYO API keys — Hunter, NeverBounce, ZeroBounce)
- Or set a hard threshold: if
bounces_today / sends_today > 0.05, pause sending for the rest of the day and notify the tenant
Don’t: use the user’s primary mailbox for “spray” patterns
Section titled “Don’t: use the user’s primary mailbox for “spray” patterns”Cold outbound to 200 people you don’t know belongs on a separate sending domain, not the tenant’s main mailbox. If your app is structurally about cold outreach, your marketplace listing should be honest: “We recommend connecting a secondary sending domain via M365 Exchange Online before going past 30 sends/day.”
The platform might ship dedicated secondary-domain support in a future release; until then this is a process matter, not a tech fix.
Don’t: silently drop sends when the cap is reached
Section titled “Don’t: silently drop sends when the cap is reached”# ❌ DON'T do thisif not await can_send_today(ctx, user_id): return # silently swallowThe cadence keeps trying to send the same message; the lead never
hears from you; the user has no idea the cap was hit. Defer
explicitly — write to app_records that the touch was
deferred, surface it in your app’s UI, retry tomorrow.
When this doesn’t apply
Section titled “When this doesn’t apply”- Sub-handful sends per tenant. If your app sends 1-5 emails per day per tenant, you don’t need this. M365 absorbs that fine. The complexity isn’t worth it.
- Transactional, recipient-initiated mail. Order confirmations, password resets, support replies — all of those are responses to user action, count against the (much higher) transactional budget, and don’t damage reputation the way cold outbound does.
- Replies in an existing thread.
email_replyon an existing thread doesn’t trigger the same spam-filter scrutiny as a cold subject line into a never-seen inbox. Don’t cap replies the way you cap cold sends.
Related
Section titled “Related”- Reacting to emails —
use
email_replyfor thread responses (not capped here). - Multi-step workflows — heartbeats are the natural place to enforce pacing.
- BYO API keys — for plugging in a deliverability provider (Hunter, NeverBounce).
- Install settings — to expose the cap + sending-window to tenant admins.