Osy#betaRun it on Osyrin: osyrin.com

Kits · Mail

Microsoft 365

Microsoft 365 through Graph — mail, calendar and files, as the signed-in user or as the app.

Package
Osysharp.Graph
Install
use Osysharp.Graph@0 {
  egress "graph.microsoft.com";
  secret "GraphToken";
}
Group
Mail
Tests
19 in the kit’s own suite
Builds on
—

Mail, calendar and files through Microsoft Graph, from a class written entirely in Osy# over the public Http.* surface.

Use case

Any business app that has to meet people where their work already is: read their calendar to find a slot, file a document in their OneDrive, send a mail that comes from them rather than from a no-reply address. Graph is one API over the whole of Microsoft 365, so this is the single highest-value integration for an app used inside a company.

The thing to get right, and the one line that decides it

Graph answers the same URL differently depending on which credential you present.

what it isGET /me/messages means
Delegatedacting as the signed-in user, over what they can seetheir mail
Applicationacting as the app across the whole tenant, with admin consentnothing — there is no /me

An integration that conflates the two is either broken or an incident. This kit does not choose for you, and it cannot — the choice is made where a reviewer can see it, in how the app declares its secret:

osy

app.Secrets = [ new Secret("GraphToken") { UserScoped = true } ];   // delegated — one token per person
app.Secrets = [ new Secret("GraphToken") ];                         // application — one token for the tenant

Both are then read by the same line, and that line can live in one factory:

osy

GraphClient Graph() { return new GraphClient { AccessToken = Secret.GraphToken }; }

The platform resolves Secret.GraphToken against whoever is calling when the declaration says UserScoped, and against the app when it does not. Nothing in this package can tell the difference, and nothing in it needs to.

⭐ The factory compiles, and the token stays put. The compiler notes that AccessToken was assigned a secret, so the client may be returned and passed between your functions while the field itself cannot be read back out — returned, logged, stored, copied or sent to a page. Holding the client in a component is refused, and so is calling the factory from a request. For a UserScoped token the factory is re-run per call, so it resolves to whoever is asking.

⛔ This kit does not mint tokens, deliberately. Acquiring one is an OAuth flow the platform already owns (app.OAuthClients — osy docs config-oauth-clients); a second implementation here would be a second place for a refresh bug to live. What this kit does is spend a token correctly.

osy

// app.osy
app Workspace {
  model "**/*.osy";
  use Osysharp.Graph@0 {
    egress "graph.microsoft.com";
    secret "GraphToken";
  }
}

What it covers

osy

using Osysharp.Graph;

// Who the token belongs to — the cheapest call there is, and the right first one when something is wrong.
var me = Graph().Me();

// Mail
Graph().SendMail("Your invoice", "Attached.", [customer.Email]);
var inbox = Graph().Inbox(top: 25);
var more  = Graph().NextMessagePage(inbox.NextLink);

// Calendar — the VIEW, so a recurring meeting appears on each day it falls on
var week = Graph().Calendar("2026-09-27T00:00:00Z", "2026-10-04T00:00:00Z");
Graph().CreateEvent("Review",
  new GraphDateTimeZone { DateTime = "2026-09-28T09:00:00", TimeZone = "Europe/Stockholm" },
  new GraphDateTimeZone { DateTime = "2026-09-28T09:30:00", TimeZone = "Europe/Stockholm" });

// Files
var files = Graph().Files("Invoices");
Graph().UploadSmallFile("Invoices/2026-09.pdf", bytes, "application/pdf");

Five things this kit exists to get right

/calendarView, not /events/events answers the series for a recurring meeting — one row however often it repeats — so "what is on Tuesday" comes back empty for a weekly stand-up. The commonest Graph calendar bug, and it looks like missing data rather than a wrong URL.
saveToSentItems defaults to TRUEGraph defaults it to false: the mail goes and the sender has no record of ever sending it. Flipped here.
A nextLink is used exactly as givenIt is absolute and carries an opaque skip token. Prefixing the base URL 404s; rebuilding the query drops the token, which silently restarts from page one and iterates forever.
A time carries its ZONEGraph's dateTime has no offset ("2026-09-28T09:00:00"). The zone beside it is what fixes the instant — get it wrong and the meeting lands hours out, with no error anywhere.
A 401 and a 403 are different work401 is the token — expired, or minted for the wrong audience; re-acquire it, granting scopes will not help. 403 is the permission — the token is fine and the app was never granted the scope, which is a consent change in Entra ID, not a code change. The message says which, every time.

Two things are refused before the call rather than after: a mail with no recipients (Graph accepts it and delivers to nobody — a 202 for a mail that went nowhere), and a file over 4 MB (simple upload stops there; larger needs an upload session).

Also covered

$select and $filter — Inbox(top, fields) narrows the projection, InboxWhere(filter) filters at the server. ⛔ Filtering at the server or not at all: fetching pages and discarding rows locally pays for every row, in bandwidth and in throttling budget, to keep a few. (⚠ Graph often refuses $filter and $orderby together on messages — InefficientFilter — which is why InboxWhere does not order.)

Upload sessions — UploadLargeFile(path, bytes) for anything over 4 MB, in 320 KiB chunks. ⛔ Two things Graph enforces and does not explain: the chunk PUTs carry no Authorization header (the session's uploadUrl is pre-authorised — the credential is IN the URL, and Graph rejects a chunk that also presents a bearer token), and Content-Range's end is inclusive.

⚠ And it goes to a DIFFERENT HOST. Graph answers createUploadSession with an uploadUrl on a host it chooses — *.up.1drv.com, a tenant's *.sharepoint.com — so the kit declares egress consumer; and your app grants whatever its own tenant answers with. The leash names the host the first time, which is how this was found.

Change notifications — Subscribe(resource, url, expiresUtc) and Renew(id, expiresUtc). Two things bite before the API does: your endpoint must echo Graph's validationToken as text/plain within 10 seconds or the subscription cannot be created at all, and a subscription EXPIRES (~3 days for mail and calendar) and stops delivering silently.

What is not covered yet

  • Teams, Planner, SharePoint lists — Graph is enormous; this is the mail/calendar/files core.
  • Delta queries — /delta for "what changed since", which pairs with subscriptions.

Verified

Every test is about a decision above rather than about the wire shape. tests/ consumes the kit as a package (use Osysharp.Graph@0 { … }), exactly as an app does, so the grant surface is proved too.

⚠ Make one real call against your tenant before going live. Every test stubs Http.*, so a test cannot catch a field name the API does not have. The wire shapes follow Graph's own reference.

⭐ Two of those tests assert the outgoing Authorization header — including one that sends two different tokens through the same code path and checks each arrives, which is the per-user guarantee proved in Osy#. (Http.*.Stub takes (url, body, headers); the third parameter is what actually went out.) The platform half — that Secret.GraphToken resolves to the calling user in the first place — is proved by the platform's own tests.

Source

model/ — two files, no C# anywhere. 350 lines for mail, calendar and files.

Get it

Add the kit to your app’s manifest:

osy

app MyApp {
  use Osysharp.Graph@0 {
    egress "graph.microsoft.com";
    secret "GraphToken";
  }
}

Then run osy lock: it downloads the release, verifies it by its content address and pins that version in osyrin.lock. osy kits Osysharp.Graph shows what the kit is for, from inside your app.