Skip to content

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.

  1. 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.
  2. 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).
  3. Tenant trust — a user notices when their normal “send mail” suddenly fails because your app exhausted the day’s quota.
  4. Cross-app fairness — if two apps send aggressively, neither should be able to starve the other.

A send-at-scale app has three layers of protection. Implement all three.

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.

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_RATE

Combined 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 normal

When 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_reply don’t have a built-in require_approval parameter today. Approval is your app’s responsibility, surfaced via the Commitments Inbox.

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.

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 this
for 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.

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 this
if not await can_send_today(ctx, user_id):
return # silently swallow

The 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.

  • 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_reply on 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.