Email, and the scope that costs an annual audit
Why Sentloop asks Gmail only for permission to send, and what that choice costs.
What Gmail and Microsoft actually permit, checked before building. Email is the one channel where the honest answer to "will this send with everything of mine switched off?" is an unqualified yes - there is no reply window, no template to get approved, and no platform to ask permission from. What there is instead is a set of trade-offs about how much access to ask for, and those decide the architecture.
The headline
Sending as the user, from a server, with every device off: SUPPORTED. Both Gmail and Microsoft Graph publish exactly this, with ordinary OAuth and no partner programme. Unlike WhatsApp there is no Tech Provider gate, and unlike Instagram there is no 24-hour window. Anyone can register the app in an afternoon.
The real constraint is scope, and it is a business constraint, not a technical one.
The finding that shaped the design
Google splits OAuth scopes into sensitive and restricted, and the gap between them is a five-figure annual bill.
| Scope | Class | What it costs |
|---|---|---|
gmail.send |
Sensitive | Google's app verification. No security assessment. |
gmail.readonly |
Restricted | Verification plus CASA Tier 2, repeated annually |
gmail.modify |
Restricted | Same |
mail.google.com |
Restricted | Same, and it is full mailbox control |
CASA Tier 2 is a third-party security assessment, paid for by the developer, and it is not a one-time certification - it recurs every year for as long as the app holds the scope.
Sentloop asks for gmail.send and nothing else. That is not only the minimum
that works, it is the boundary between a form and an audit.
Microsoft has no CASA equivalent, but the same reasoning applies to Mail.Send
versus Mail.Read: a scheduling app has no business reading anybody's inbox.
The setting that silently breaks scheduling
Separate from scopes, and easy to miss because nothing warns you: an app whose publishing status is Testing with an External user type has its refresh tokens revoked by Google after 7 days.
For most apps that is an inconvenience. For this one it is fatal in a way that
only shows up later: Gmail connects, sends fine all week, and a birthday
scheduled for next month finds a dead connection on the day. Sentloop reports
that honestly as REAUTH_REQUIRED rather than pretending, but the message is
still late.
So the app is published In production. Unverified is fine to begin with: the consent screen carries a "Google hasn't verified this app" warning that a person can continue past, and there is a lifetime cap of 100 users until verification. Neither affects whether a scheduled send survives.
What the narrow scope costs, and where that is paid
Send-only permission means Sentloop cannot look in your Sent folder. That matters in exactly one place: after a crash, when a message was in flight and nobody knows whether it landed.
worker claims the message -> calls Gmail -> ??? process dies ???
With a read scope, the reconciler could search Sent Items and settle it. Without one, it cannot - so the reconciler is built around the limit instead of pretending it away:
- Both transports return
unsupportedfromlookupSent, neverunknown. "I looked and found nothing" and "I am not allowed to look" are different facts and the code keeps them different. - The evidence used instead is Sentloop's own
DeliveryAttemptwire log, which is written before anything is interpreted and therefore survives the crash that stranded the row. - When the evidence does not settle it, the message becomes
UNRESOLVEDand says so. It is not marked sent, and it is not marked failed.
That last point is the one worth defending. Calling it failed would invite the
user to send it again, and a duplicate greeting in somebody's inbox is a worse
outcome than a late one. See src/lib/scheduler/reconcile.ts.
Sending, per carrier
| Gmail | Microsoft Graph | |
|---|---|---|
| Endpoint | users.messages.send |
POST /me/sendMail |
| Body | raw RFC 5322, base64url | structured JSON |
| Returns a message id | Yes | No |
| Success code | 200 with the message resource | 202 with an empty body |
Microsoft's own documentation on that 202:
"A 202 Accepted response code indicates that the request has been accepted; however, it does not indicate that the request processing has completed."
So an Outlook send is recorded as accepted by Microsoft, which is the
strongest true statement available, and never as delivered. providerMessageId
is stored as null rather than being invented, and that null is what tells the
reconciler there is nothing to look up.
Header injection
Gmail takes raw MIME, which means Sentloop assembles the headers, which means a newline in a subject line is not a formatting bug:
subject: "Project Update\r\nBcc: everyone@example.com"
Left alone that becomes a real Bcc on a real message, sent from the user's own authenticated account with valid SPF alignment. Line breaks are rejected at the boundary rather than stripped, because stripping quietly alters what someone wrote and a subject containing a newline is never legitimate.
Bcc is never written into the emitted headers at all, even though every transport strips it - relying on that would stake the confidentiality of a blind list on the behaviour of whichever carrier handles it next.
The From address
A From header is just a string unless something constrains it. Unconstrained, this would be a service that sends mail claiming to be from anyone, authenticated by a real provider, from a reputable IP.
So the authorised set never comes from user input. It comes from what the
provider itself said the account was, learned at connect time from
openidconnect.googleapis.com/v1/userinfo or Graph's /me. A requested address
that does not match is refused rather than rewritten - Gmail would rewrite it
silently anyway, and someone who believes they are sending as one address while
the recipient sees another has been misled by their own tool.
Aliases and send-as addresses are not supported yet. Reading them needs
gmail.settings.basic, and that is a scope decision worth taking deliberately
rather than by accident.
Why the carrier is never guessed from the domain
ivan@company.com -> Google Workspace?
-> Microsoft 365?
-> a mail server in a cupboard?
The domain cannot tell you. A domain lookup answers correctly for @gmail.com
and @outlook.com and silently mis-routes every custom domain, which is exactly
the population most likely to be sending mail that matters.
Routing therefore reads the carrier recorded on the connection when it was
authorised. suggestProviderFromDomain exists separately, is used only to
highlight a button on the connect screen, and returns null far more often than
it returns an answer. The two are kept apart deliberately, because they get
conflated precisely when one function ends up serving both.
What is still open
Honest list, so nothing here reads as finished when it is not.
- Microsoft has its own scheduled failure, and it is worth knowing before you start. A Graph client secret is capped at 24 months in the portal, and Microsoft suggests less. Google's client secret does not expire at all, so Outlook is the only carrier here that arrives with an end date attached: when it passes, every Outlook send fails until a new secret is pasted in. Worth a calendar reminder on the day it is created.
- Google is registered; Microsoft is not. The Sentloop Google Cloud project
exists, the Gmail API is on,
gmail.sendis the only scope, and the client is wired tohttps://sentloop.com/api/connect/email/callback. Google's own consent screen loads for it. Microsoft is still an operator task. - The real-world acceptance test in section 40 has not been run, because it
needs a mailbox actually connected. What has been proven on this
deployment is the equivalent for the server transport: a message sent at
15:11:02Zfor a15:11:00Zschedule with nothing of the user's running. - Attachments are designed for in the schema but not enabled.
- Bounce webhooks (Gmail push via Pub/Sub, Graph change notifications) are not implemented. Delivery status stops at Sent, which is the last thing either API actually establishes.