Guide Automation

Email sending limits for scripts: Gmail, Workspace, Microsoft 365

Gmail stops at 500 a day, Workspace at 2,000, Microsoft 365 at 10,000 recipients and 30 a minute. The limits, the error codes and a paced sender.

The short read

Microsoft 365 reports a full daily quota with code 5.1.90, which a script can mistake for one bad address.

  • Gmail allows 500 emails a day, Workspace 2,000 messages and 2,000 unique outside addresses, Microsoft 365 10,000 recipients and 30 messages a minute per mailbox.
  • Our Python sender paced, stopped and resumed without duplicates in 7 tests on a local server with 25 invented rows, not a real provider.

What you can do with this

Before a script mails a list, set its daily cap below your account's smallest limit and treat only 5.1.1 and 5.1.10 as bad addresses. Anything else should stop the run.

A tall untidy stack of papers in a wooden tray labelled IN BOX beside a nearly empty tray labelled OUT BOX.
Photo: philippechazal, CC0
In this article
  1. The limits by route, read October 27, 2026
  2. Three ways the counting goes wrong
  3. Microsoft 365’s tenant limit is shared by every mailbox
  4. What hitting a limit looks like
  5. A sender that paces, stops and resumes
  6. What our test on a local SMTP server showed
  7. Two bugs the test caught
  8. When a mailbox is the wrong tool
  9. Set your numbers before the first run
  10. How we made this

A script that emails a list from an ordinary mailbox hits limits the mail client never shows. Personal Gmail stops at 500 emails a day. A Google Workspace user stops at 2,000 messages and 2,000 unique outside addresses, then can’t send for up to 24 hours. A Microsoft 365 mailbox allows 10,000 recipients a day and 30 messages a minute, plus a company-wide cap on outside recipients.

Hit one of these and the mailbox stops for everyone who uses it, not only the script.

This guide puts the limits for each route in one table and explains how each is counted. It lists the error codes each provider returns and gives a Python sender that stays under them. We read every limit on October 27, 2026, and tested the script against a local SMTP server we set up to act like a provider at its limit.

The limits by route, read October 27, 2026

How you send decides which limit applies. The same Workspace user has one daily limit in Gmail, a lower per-message cap over SMTP, and a much higher one through Google’s SMTP relay.

Route Per day Per message Per minute Source
Personal Gmail 500 emails; one email to more than 500 recipients triggers the block 500 recipients Not stated Gmail Help
Workspace, Gmail or SMTP 2,000 messages; 10,000 total recipients; 3,000 unique recipients, of which 2,000 external 2,000 in Gmail (500 external); 100 over SMTP Not stated Workspace limits
Workspace, Gmail API The Workspace daily limits, which the API page links to 500 recipients 60 sends per user, by quota units Gmail API quota
Workspace trial 500 messages; 500 unique external recipients As above Not stated Workspace limits
Workspace SMTP relay 10,000 unique recipients and 10,000 messages per user 100 per SMTP transaction 319,444 recipients per 10 minutes for the whole organization SMTP relay
Apps Script MailApp 100 recipients (personal), 1,500 (Workspace) 50 recipients Not stated Apps Script quotas
Microsoft 365 mailbox, every plan listed 10,000 recipients Up to 1,000 recipients, set by the admin 30 messages Exchange Online limits
Microsoft 365 tenant, outside recipients Depends on licenses (next section) Exchange Online limits
Microsoft 365, from an onmicrosoft.com address 100 outside recipients per tenant Exchange Online limits

One cell is our arithmetic, not a number the vendor prints. The Gmail API gives each user 6,000 quota units a minute and charges 100 units for messages.send. That caps one user at 60 sends a minute. The Workspace page was last updated October 22, 2026, the Apps Script page September 3, 2026, the Gmail API page October 16, 2026, and Microsoft’s page October 19, 2026.

The SMTP relay row needs an admin with the Gmail Settings privilege to set it up in the Admin console. Google lowers relay limits for trial accounts and for accounts with an unpaid bill. Workspace’s ordinary limits also rise on their own once a domain has paid at least $100 in total, which Google says can take up to 75 days to show.

Three ways the counting goes wrong

The numbers above look comparable. They are counted three different ways, and each can stop a script that looked safely under the headline figure.

Rolling window or fixed reset. Workspace limits apply “over a rolling 24-hour period, not a set time of day.”

Microsoft counts the same way. In its example, a user who sends to 5,000 recipients at 09:00, 2,500 at 10:00 and 2,500 at 11:00 has used all 10,000 and “won’t be able to send messages again until 09:00 AM the next day.” Apps Script is different again: its quotas “reset 24 hours after the first request.” So a script that waits for midnight UTC gains nothing on any of them.

Messages, recipients or unique recipients. Google counts recipients each time: “5 messages sent to 10 addresses count as 50 total recipients.” Its unique-recipient limit counts each address once a day.

Microsoft counts a distribution group from the shared address book as one recipient for the mailbox limit. Its tenant-wide external limit counts “distribution group members individually.” So one message to a group with 1,000 outside members uses 1,000 of the tenant’s allowance.

Per user or per company. Most limits belong to one sender. Microsoft’s external recipient limit belongs to the tenant, so a script on one mailbox can stop the sales team’s outside mail. Google counts mail from a user’s aliases, from delegates and from the vacation responder against that user.

Microsoft 365’s tenant limit is shared by every mailbox

Microsoft calls it the Tenant External Recipient Rate Limit, or TERRL: “the maximum number of external recipients a tenant can send to per day (24-hour sliding window).” It depends on the number of licenses. Microsoft’s limits page names the report that shows it: Reports, then Mail flow, then Tenant Outbound External Recipients in the Exchange admin center.

The formula itself is in Microsoft’s message center post MC1454397 (published August 14, 2026, updated September 29, 2026, read through a public mirror of the message center). For paid tenants the existing formula “remains unchanged: 500 × (Purchased Email Licenses^0.7) + 9500.” We worked it out for common tenant sizes:

Licenses Outside recipients per 24 hours First 30 days of a new tenant (10%) Days 31 to 60 (25%)
1 about 10,000 about 1,000 about 2,500
10 about 12,006 about 1,201 about 3,001
25 about 14,259 about 1,426 about 3,565
50 about 17,231 about 1,723 about 4,308
100 about 22,059 about 2,206 about 5,515

Microsoft doesn’t say how it rounds, so treat the last digit loosely.

The same post cuts new tenants to 10% of that figure in their first 30 days and 25% from day 31 to 60. Trial tenants drop from 5,000 to 500 outside recipients a day. That rollout runs from mid-September to the end of November 2026, while Microsoft’s limits page, updated October 19, 2026, still gives trial tenants 5,000. Check the admin center report rather than either figure.

Take a 10-license tenant. A script that spends its mailbox’s full 10,000 on outside addresses leaves the other nine people about 2,006 for the rest of the 24 hours. On a two-week-old tenant the whole company has about 1,201, far below any one mailbox’s limit.

What hitting a limit looks like

Scripts see these as SMTP replies or, for Microsoft, as non-delivery reports. The codes below are from Google’s SMTP error list (updated October 22, 2026) and Microsoft’s NDR reference (updated August 24, 2026).

Code Provider Meaning What the script should do
550 5.4.5 Google “Daily user sending limit exceeded” or “Daily SMTP relay limit exceeded for user” Stop
550 5.5.3 Google “Too many recipients for this sender” Stop
452 4.5.3 Google “Your message has too many recipients” Send to fewer recipients per message
550 5.7.1 Google Includes “Daily SMTP relay sending limit exceeded for this customer” Stop
5.1.90 Microsoft “you’ve reached your daily limit for message recipients” Stop
5.2.2 Microsoft “Submission quota exceeded” Stop
5.7.233 Microsoft The tenant exceeded its daily limit for outside recipients (5.7.232 on a trial tenant) Stop; it blocks the whole tenant
5.7.236 Microsoft Over 100 outside recipients from onmicrosoft.com addresses Stop and send from your own domain
550 5.1.1 Both Google: “The email account that you tried to reach does not exist”; Microsoft: “Bad destination mailbox address” Skip that row and go on
5.1.10 Microsoft “Recipient not found” Skip that row and go on

Look at 5.1.90. A tempting shortcut in a script treats any code that starts with 5.1 as a bad address, logs the row as failed and moves on.

Microsoft uses 5.1.90 for a full daily quota, and 5.1.0 means “Sender denied.” A script with the prefix rule keeps going through the list and marks every remaining person as undeliverable. Our test below shows how many rows that loses.

Microsoft’s per-minute limit is softer. Its page says excess messages “will be throttled and successively carried over to the following minutes.” That “will typically not block the sender’s account.”

The same page says messages sent faster over SMTP client submission “will be rejected and the client will need to retry.” Pacing below 30 a minute avoids depending on either.

A sender that paces, stops and resumes

The script below sends one message per row of a CSV file over SMTP. It uses only Python’s standard library. Four rules guard against the failures above:

  • It waits 60 / per-minute seconds between messages, 3 seconds at the default of 20.
  • It logs each recipient with the UTC time and counts the last 24 hours before sending, so its window rolls the way Google and Microsoft count.
  • It skips a row only on 5.1.1 or 5.1.10. Any other refusal, any 4xx reply and a dropped connection stop the run with exit code 3.
  • It writes each log line, and forces it to disk, right after the server accepts the message, so a second run skips everyone already done.
  • It locks the log on Linux and macOS, so a second copy started while the first is running stops instead of sending the same rows. On Windows it warns that it can’t.
#!/usr/bin/env python3
"""Send one email per CSV row without running into the provider's sending limits.

Paces messages, counts a rolling 24 hours from a log file, stops on a limit
error, and skips rows already handled when you run it again. Standard library
only, Python 3.9 or later.
"""
import argparse, csv, json, os, smtplib, ssl, sys, time
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import make_msgid
from string import Template

# Codes that are about one bad address, so the run may go on. Everything else
# stops it, including Microsoft's 5.1.90 (daily recipient limit), which shares
# the 5.1 prefix, and 5.1.0 (sender denied).
SKIP_ONE = ("5.1.1", "5.1.10")


def now():
    return datetime.now(timezone.utc)


def read_log(path):
    done, times = set(), []
    if not os.path.exists(path):
        return done, times
    if os.path.getsize(path):
        with open(path, "rb+") as f:  # a crash can leave half a line; start the next on its own
            f.seek(-1, 2)
            if f.read(1) != b"\n":
                f.write(b"\n")
    with open(path, encoding="utf-8") as f:
        for n, line in enumerate(f, 1):
            try:
                e = json.loads(line)
                done.add(e["to"].lower())
                times.append(datetime.fromisoformat(e["time"]))
            except (ValueError, KeyError):
                print(f"log line {n} unreadable, ignored", file=sys.stderr)
    return done, times


def append_log(path, to, status, detail=""):
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps({"time": now().isoformat(), "to": to,
                            "status": status, "detail": detail}) + "\n")
        f.flush()
        os.fsync(f.fileno())


def enhanced_code(text):
    parts = text.split()
    return parts[0] if parts and parts[0].count(".") == 2 else ""


def main():
    p = argparse.ArgumentParser()
    p.add_argument("csv")
    p.add_argument("--log", default="sent-log.jsonl")
    p.add_argument("--daily-cap", type=int, required=True,
                   help="most recipients in any 24 hours, below your provider's limit")
    p.add_argument("--per-minute", type=float, default=20)
    p.add_argument("--host", required=True)
    p.add_argument("--port", type=int, default=587)
    p.add_argument("--tls", choices=["starttls", "ssl", "none"], default="starttls")
    p.add_argument("--user", help="password comes from the SMTP_PASSWORD variable")
    p.add_argument("--sender", required=True)
    p.add_argument("--subject", required=True)
    p.add_argument("--body", required=True, help="text file; $name and other CSV columns are filled in")
    p.add_argument("--dry-run", action="store_true")
    a = p.parse_args()
    if a.daily_cap < 1 or a.per_minute <= 0:
        sys.exit("--daily-cap and --per-minute must be above 0")

    with open(a.body, encoding="utf-8") as f:
        body = Template(f.read())
    with open(a.csv, newline="", encoding="utf-8-sig") as f:
        rows = list(csv.DictReader(f))
    if rows and "email" not in rows[0]:
        sys.exit("the CSV needs an 'email' column")

    lock = open(a.log + ".lock", "w")
    try:  # two runs at once would send the same rows twice
        import fcntl
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except ImportError:
        print("no file locking on this system; never start two runs at once", file=sys.stderr)
    except OSError:
        sys.exit("another run is using this log; stopping")

    done, times = read_log(a.log)
    todo, seen = [], set(done)
    for r in rows:
        to = (r.get("email") or "").strip()
        if "@" not in to:
            print(f"skipped, not an address: {to!r}")
        elif to.lower() not in seen:
            seen.add(to.lower())
            todo.append((to, r))
    window = [t for t in times if t > now() - timedelta(hours=24)]
    room = a.daily_cap - len(window)
    print(f"{len(todo)} to send, {len(done)} already in the log, "
          f"{len(window)} in the last 24 hours, room for {max(room, 0)}")
    if not todo:
        return 0
    if a.dry_run:
        for to, _ in todo[:max(room, 0)]:
            print("would send to", to)
        return 0

    server = None
    if room > 0:
        if a.tls == "ssl":
            server = smtplib.SMTP_SSL(a.host, a.port, context=ssl.create_default_context(), timeout=60)
        else:
            server = smtplib.SMTP(a.host, a.port, timeout=60)
            if a.tls == "starttls":
                server.starttls(context=ssl.create_default_context())
        if a.user:
            server.login(a.user, os.environ["SMTP_PASSWORD"])

    gap, last, sent, code = 60 / a.per_minute, 0.0, 0, 0
    for to, row in todo:
        if room <= 0:
            window.sort()
            free = window[len(window) - a.daily_cap] + timedelta(hours=24)
            print(f"daily cap reached; next room at {free:%Y-%m-%d %H:%M} UTC. Run again then.")
            code = 3
            break
        wait = last + gap - time.monotonic()
        if wait > 0:
            time.sleep(wait)
        msg = EmailMessage()
        msg["From"], msg["To"], msg["Subject"] = a.sender, to, a.subject
        msg["Message-ID"] = make_msgid()
        msg.set_content(body.safe_substitute(row))
        last = time.monotonic()
        try:
            server.send_message(msg)
        except smtplib.SMTPRecipientsRefused as e:
            c, text = e.recipients[to]
            text = text.decode(errors="replace")
            if c >= 500 and enhanced_code(text) in SKIP_ONE:
                append_log(a.log, to, "rejected", f"{c} {text}")
                window.append(now()); room -= 1
                print(f"rejected {to}: {c} {text}")
                continue
            print(f"stopped at {to}: {c} {text}")
            code = 3
            break
        except smtplib.SMTPResponseException as e:
            print(f"stopped at {to}: {e.smtp_code} {e.smtp_error.decode(errors='replace')}")
            code = 3
            break
        except (smtplib.SMTPServerDisconnected, OSError) as e:
            print(f"connection lost at {to}: {e}. Check whether it arrived before running again.")
            code = 3
            break
        append_log(a.log, to, "sent")
        window.append(now()); room -= 1; sent += 1
    if server:
        try:
            server.quit()
        except smtplib.SMTPException:
            pass
    print(f"sent {sent} this run")
    return code


if __name__ == "__main__":
    sys.exit(main())

Save it as send_paced.py. The CSV needs an email column; every other column can appear in the body as $name, $invoice and so on. Run it with --dry-run first to see the count and who would get mail:

export SMTP_PASSWORD='...'
python3 send_paced.py customers.csv --host smtp.example.com --user you@example.com \
  --sender you@example.com --subject "Your October invoice" --body body.txt \
  --daily-cap 1500 --per-minute 20 --dry-run

Remove --dry-run to send. When it prints “daily cap reached”, it also prints the UTC time when room opens; run the same command again then. A scheduler can run it every hour: a run with nothing to do sends nothing, and the lock stops a run that starts while the last one is still going.

Rejected addresses use room in the window too. We don’t know whether Google or Microsoft count a refused recipient, so the script assumes they do.

What our test on a local SMTP server showed

We had no Gmail or Microsoft 365 account to send from. So we ran the script on Python 3.11 against a local SMTP server built with aiosmtpd 1.4.6.

The server accepted mail until a set number of recipients, then answered every recipient with the limit reply we chose. The list was 25 invented rows. It held 22 valid example.com addresses, one of them repeated in capitals, a row with “n/a” for an address, and one address our server rejected with 5.1.1.

Test What happened
Full run at 30 a minute 22 delivered, 1 rejected and logged, “n/a” and the repeat skipped; 44.2 seconds, every message 2.00 seconds after the last
Same command again 0 sent: all 23 addresses were in the log
Server answers 550 5.4.5 after 10 Stopped at row 11 with exit code 3; a second run against a fresh server sent the other 12; 22 delivered, no address twice
Server answers 5.1.90 after 5 Stopped at row 6; nothing marked as rejected
Process killed with kill -9 after 5 seconds Log and server both had 11; the next run sent the other 11, no duplicates
Cap of 10, log holding 8 sends from 23 hours 50 minutes ago Sent 2, stopped, and named the time 10 minutes later when room opens
Second run started 1 second after the first, same log Second run refused to start; the first sent all 22, none twice

Two bugs the test caught

Two results changed the script. We ran the 5.1.90 test again with the prefix rule (“skip anything starting with 5.1”). It logged 18 rows as rejected: 17 people whose only problem was the quota, plus the one real bad address. A later run would never have mailed those 17.

The second was a half-written log line. We cut the last line of a log in half, as a crash during a write would. The first version then appended its next entry to the broken line.

That entry became unreadable, and a second run sent person11@example.com a duplicate. The version above starts a new line first; the same test then sent nobody twice.

This test can’t show how Gmail or Exchange Online time their replies, or at which step they refuse. Nor does it show whether they count refused recipients.

One gap remains by design. If the connection drops after the server accepts a message but before the log line is written, a re-run sends that message again. That is why the script tells you to check first.

When a mailbox is the wrong tool

Both vendors say the ordinary mailbox is not for bulk mail. Microsoft tells customers who send “legitimate bulk commercial email (for example, customer newsletters)” to “use third-party providers that specialize in these services.” Google’s sender guidelines set extra rules for anyone sending more than 5,000 messages a day to Gmail accounts: SPF, DKIM and DMARC, one-click unsubscribe on marketing mail, and a spam rate in Postmaster Tools below 0.3%.

So use this script for transactional or one-to-one mail from your own mailbox: invoices, reminders, renewal notices to existing customers, internal notices. Don’t use it for a newsletter, for cold outreach or for anything people did not ask for. It sends one message at a time from one person’s account, which suits a few hundred expected messages, not a campaign.

Microsoft 365 adds a deadline. The script signs in with a password, and Microsoft turns that kind of SMTP sign-in off by default at the end of December 2026; we covered the replacements, including Graph sendMail and High Volume Email for internal recipients, in our SMTP AUTH piece. Graph has its own ceiling of 10,000 requests per 10 minutes and four concurrent requests per app and mailbox, and Microsoft’s mailbox limits apply to “users and applications” alike. For a Google Sheet with a PDF per row, our mail merge guide does the same pacing inside Apps Script with MailApp.getRemainingDailyQuota().

Set your numbers before the first run

  1. Find your route in the first table: personal Gmail, Workspace by Gmail or SMTP, Workspace relay, Apps Script or Microsoft 365. A Workspace trial or a tenant under 60 days old has lower limits.
  2. Take the smallest limit that applies. For one recipient per message to outside addresses on Workspace, that is 2,000 unique external recipients. On Microsoft 365 it is the tenant’s outside-recipient figure in the admin center report, shared with everyone, not the mailbox’s 10,000.
  3. Set --daily-cap well below it and leave room for the person’s own mail. Our suggestion, not a vendor rule: 400 on personal Gmail, 1,500 on Workspace, and on Microsoft 365 a share of the tenant figure you have agreed with whoever runs it.
  4. Keep --per-minute at 20 or less on Microsoft 365, under its 30.
  5. Send from your own domain, never an onmicrosoft.com address, which stops at 100 outside recipients a day for the whole tenant.
  6. Run once with --dry-run, then once to a CSV holding only your own address, then the real list.

How we made this

We read Google’s and Microsoft’s limit, quota, error-code and sender pages on October 27, 2026, and Microsoft’s message center post MC1454397 through a public mirror. Microsoft’s own blog post on the tenant limit blocked our requests. We wrote the script and ran the 7 tests in the table and the half-written log test against a local SMTP server, on 25 invented rows; two comparison runs used earlier versions. We did not send through Gmail, Workspace or Microsoft 365, so how those services reply at their limits is taken from their documentation, not observed.

Sources