Ozakboy.Gmail
2.1.0
dotnet add package Ozakboy.Gmail --version 2.1.0
NuGet\Install-Package Ozakboy.Gmail -Version 2.1.0
<PackageReference Include="Ozakboy.Gmail" Version="2.1.0" />
<PackageVersion Include="Ozakboy.Gmail" Version="2.1.0" />
<PackageReference Include="Ozakboy.Gmail" />
paket add Ozakboy.Gmail --version 2.1.0
#r "nuget: Ozakboy.Gmail, 2.1.0"
#:package Ozakboy.Gmail@2.1.0
#addin nuget:?package=Ozakboy.Gmail&version=2.1.0
#tool nuget:?package=Ozakboy.Gmail&version=2.1.0
<img src="https://raw.githubusercontent.com/ozakboy/ozakboy.Gmail/main/logo.png" width="112" align="right" alt="Ozakboy.Gmail" />
Ozakboy.Gmail
Gmail REST and Google OAuth in one thin async client. You keep the tokens; it keeps the HTTP.
English | 繁體中文 · Changelog · API Reference · NuGet
A server that reads a support mailbox every five minutes, labels what it finds, trashes the ads and sends the odd reply. That job needs the Gmail API and a refresh token you store yourself — encrypted, per tenant, on your terms. It does not need the whole Google.Apis authentication stack deciding where tokens live. Ozakboy.Gmail is the client for that job: one HttpClient, one delegate that hands over an access token, and the Gmail REST surface you actually use.
🔑 · · · ✉
Read a mailbox
using Ozakboy.Gmail;
IGmailClient gmail = new GmailClient(httpClient, ct => Task.FromResult(accessToken));
GmailProfile profile = await gmail.GetProfileAsync();
GmailMessageList page = await gmail.ListMessagesAsync("newer_than:30d", maxResults: 100);
foreach (GmailMessageRef reference in page.Messages)
{
GmailMessage message = await gmail.GetMessageAsync(
reference.Id!,
GmailMessageFormat.Metadata,
new[] { "From", "Subject", "List-Unsubscribe" });
Console.WriteLine($"{message.GetHeader("From")} {message.GetHeader("Subject")}");
}
Label it, trash it, or reply:
await gmail.BatchModifyLabelsAsync(adIds, addLabelIds: new[] { adsLabelId }, removeLabelIds: null);
await gmail.TrashAsync(messageId); // recoverable for 30 days — there is no permanent delete here
var reply = new GmailOutgoingMessage { Subject = "Re: Quotation", HtmlBody = "<p>Attached.</p>", InReplyTo = originalMessageId };
reply.To.Add(new GmailAddress("customer@example.com", "Customer"));
reply.Attachments.Add(new GmailAttachmentContent { FileName = "quote.pdf", ContentType = "application/pdf", Content = pdfBytes });
GmailMessage sent = await gmail.SendAsync(reply, threadId: originalThreadId);
And decide what to do when Google says no:
try
{
GmailHistoryList history = await gmail.ListHistoryAsync(storedHistoryId);
storedHistoryId = history.HistoryId!;
}
catch (GmailApiException ex) when (ex.IsHistoryExpired) { /* rescan by time window */ }
catch (GmailApiException ex) when (ex.IsUnauthorized) { /* ask the user to re-authorize */ }
Connect a mailbox
using Ozakboy.Gmail.OAuth;
IGoogleOAuthClient oauth = new GoogleOAuthClient(httpClient, GoogleOAuthOptions.FromConfiguration(configuration));
// 1. send the user here
string url = oauth.BuildAuthorizationUrl(redirectUri, new[] { GmailScopes.GmailModify, GmailScopes.OpenId, GmailScopes.Email }, state);
// 2. on the callback
GoogleTokenResponse token = await oauth.ExchangeCodeAsync(code, redirectUri);
var who = GoogleIdTokenPayload.Parse(token.IdToken!); // who.Subject, who.Email
// store token.RefreshToken (encrypted), token.AccessToken, token.ExpiresAt
// 3. later: a provider that caches, refreshes ahead of expiry and tells you when to persist
var provider = new GoogleAccessTokenProvider(oauth, refreshToken, new GoogleAccessTokenProviderOptions
{
OnRefreshed = (fresh, ct) => store.SaveAsync(fresh.AccessToken!, fresh.ExpiresAt, ct),
});
IGmailClient gmail = new GmailClient(httpClient, provider.GetAccessTokenAsync);
What this is
A deliberately thin layer over two Google HTTP APIs:
GmailClient— profile, list / get messages (metadata, full, or the raw RFC 822 bytes),history.listfor incremental sync, batch get for backfills, threads, modify / batch-modify labels, trash / untrash, report spam, labels CRUD, attachments, andmessages.send— with a built-in RFC 822 writer (GmailOutgoingMessage) or your own bytes (SendRawAsync)GoogleOAuthClient— authorization URL, code exchange, refresh, revoke, and reading theid_tokenclaims;GoogleAccessTokenProviderkeeps one mailbox's access token cached and refreshed for youGmailApiException— every non-2xx response, with Google's error reason and four flags a sync loop needs:IsUnauthorized,IsHistoryExpired,IsRateLimited,IsNotFound
And what it is not: it does not store or encrypt tokens, does not watch Pub/Sub, does not delete permanently, does not speak IMAP or SMTP, does not cover other Google APIs, and does not classify mail. Those live in your application.
Why it is different
No Google.Apis, no opinions about tokens. The client asks a Func<CancellationToken, Task<string>> for an access token before each request and never sees a refresh token. Encrypt them with your own key, scope them per tenant, rotate them on your schedule — the library has no cache to fight.
gmail.modify is the only scope you need. Sending goes through REST messages.send (the built-in writer produces the RFC 822 message, the client uploads it as message/rfc822), not SMTP. SMTP and IMAP with OAuth require the full https://mail.google.com/ scope; this library never asks for it.
Errors you can branch on. Gmail's per-user quota comes back as HTTP 403, an expired startHistoryId as 404, a dead refresh token as 400 invalid_grant. You do not parse any of that: 429, 5xx and the rate-limit 403s are retried with exponential backoff (three times from one second, Retry-After honoured), and what still fails arrives as one exception type with the right flag set.
No MIME library either. Since 2.0.0 the package depends on nothing beyond the two Microsoft.Extensions.Configuration binding packages (and System.Text.Json on .NET Standard). GmailOutgoingMessage writes text + HTML bodies, attachments, Reply-To, Bcc, threading and custom headers itself; anything fancier, build with MimeKit in your own project and call SendRawAsync.
Async all the way down. No synchronous API, ConfigureAwait(false) on every await, OperationCanceledException never wrapped, models are plain classes with the Gmail REST field names.
Install
dotnet add package Ozakboy.Gmail
Then follow Getting Started — it walks through the Google Cloud setup (Gmail API, consent screen, OAuth client), the authorization flow, a token provider that refreshes itself, and a first sync loop.
✉ · · · 🔑
Compatibility
| Target framework | Supported |
|---|---|
| .NET 10.0 | ✅ |
| .NET 9.0 | ✅ |
| .NET 8.0 | ✅ |
| .NET Standard 2.1 | ✅ |
| .NET Standard 2.0 | ✅ |
.NET Standard 2.0 covers .NET Framework 4.6.1+, .NET Core 2.0+ and Mono/Xamarin/Unity.
Dependencies: Microsoft.Extensions.Configuration.Abstractions, Microsoft.Extensions.Configuration.Binder, and System.Text.Json on .NET Standard only. No MIME library since 2.0.0 — see the migration guide if you are upgrading from 1.0.0.
Documentation
| Getting Started | Google Cloud setup, OAuth flow, token provider, first sync |
| Configuration | GoogleOAuthOptions, GoogleAuthorizationUrlOptions, GmailClientOptions, the refresh-token gotchas |
| API Reference | Every public member, parameter, exception flag and null rule |
| Migration | 1.0.0 → 2.0.0 |
| Changelog | Version history |
License
MIT.
Support
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 is compatible. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
- System.Text.Json (>= 8.0.5)
-
.NETStandard 2.1
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
- System.Text.Json (>= 8.0.5)
-
net10.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
-
net8.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
-
net9.0
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v2.1.0 — faster backfill, thread endpoints, body / attachment helpers, a ready-made access-token provider, and finer retry control. No breaking changes.
ADDED:
- BatchGetMessagesAsync(ids, format, metadataHeaders) — fetches many messages through the Gmail batch endpoint (multipart/mixed, up to GmailClientOptions.BatchSize = 50 per HTTP request, chunked automatically). Returns GmailBatchGetResult with Messages and per-id Failures (deleted messages, per-item rate limits) instead of failing the whole batch.
- Thread endpoints: GetThreadAsync, ModifyThreadAsync, TrashThreadAsync, UntrashThreadAsync, with the GmailThread model.
- GmailMessage.GetTextBody() / GetHtmlBody() — walk the MIME parts Gmail already split and return the decoded body; GmailMessage.GetAttachments() — every attachment part as GmailAttachmentInfo (part id, file name, MIME type, size, attachment id, Content-ID).
- GoogleAccessTokenProvider — caches the access token, refreshes it ahead of expiry (RefreshSkew, default 2 minutes), serialises concurrent refreshes, and reports each refresh through OnRefreshed so you can persist it. Pass provider.GetAccessTokenAsync to GmailClient.
- GmailApiException.RetryAfter — the Retry-After value of the final failed response, for job-level backoff.
- GmailClientOptions.MaxRetryDelay (default 60 s) — a Retry-After longer than this is not waited for; the exception is thrown immediately with RetryAfter set. GmailClientOptions.RetryOnNetworkErrors (default false) — retry HttpRequestException with the same backoff.
FIXED:
- On .NET Framework, a 204 No Content response (batchModify, labels.delete, revoke) threw NullReferenceException because HttpResponseMessage.Content can be null there. Found by the new net48 test target.
TECHNICAL:
- The test project now also targets net48 and runs every test against the netstandard2.0 build.
Full changelog: https://github.com/ozakboy/ozakboy.Gmail/blob/main/docs/en/changelog.md