FlexiMail 0.3.0
dotnet add package FlexiMail --version 0.3.0
NuGet\Install-Package FlexiMail -Version 0.3.0
<PackageReference Include="FlexiMail" Version="0.3.0" />
<PackageVersion Include="FlexiMail" Version="0.3.0" />
<PackageReference Include="FlexiMail" />
paket add FlexiMail --version 0.3.0
#r "nuget: FlexiMail, 0.3.0"
#:package FlexiMail@0.3.0
#addin nuget:?package=FlexiMail&version=0.3.0
#tool nuget:?package=FlexiMail&version=0.3.0
<p align="center"> <img src="https://github.com/mabroukmahdhi/FlexiMail/blob/main/FlexiMail/icmail.png" alt="FlexiMail logo"> </p>
FlexiMail
FlexiMail is a test-driven email client for .NET 8 and C# 12 that now supports both Exchange (EWS) and Microsoft Graph through the new FlexiGraphService.
Features
- Exchange and Microsoft Graph mail sending with sent-items copy
- Microsoft Graph inbox reading, including bodies and file attachments
- Microsoft Graph webhook subscription management for new inbox messages
- License-free Exchange Online shared-mailbox provisioning
FlexiGraphServicefor Graph-based delivery- Asynchronous APIs
- Test-first design with unit and integration coverage
Installation
dotnet add package FlexiMail
# or
Install-Package FlexiMail
Usage
Note: The Exchange constructor of
FlexiMailClientis compiled only fornet8.0andnet9.0. When targetingnet10.0, use the Graph constructor (FlexiMailClient(GraphMailConfigurations)).
Send via Exchange (EWS)
using FlexiMail;
using FlexiMail.Models.Configurations;
using FlexiMail.Models.Foundations.Bodies;
using FlexiMail.Models.Foundations.Messages;
var configurations = new ExchangeConfigurations
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret",
TenantId = "your-tenant-id",
Authority = "https://login.microsoftonline.com/{tenantId}",
Scopes = ["https://outlook.office365.com/.default"],
SmtpAddress = "sender@domain.com"
};
var client = new FlexiMailClient(configurations);
await client.SendAndSaveCopyAsync(new FlexiMessage
{
To = ["email@domain.com"],
Subject = "Hello from FlexiMail",
Body = new FlexiBody
{
Content = "This is the message body.",
ContentType = BodyContentType.PlainText
}
});
Read received email with Microsoft Graph
Inbound APIs require a Graph-configured client. When mailbox is omitted,
SenderUserIdOrUpn is used.
var page = await client.GetInboxAsync(
mailbox: "support@domain.com",
pageSize: 50,
unreadOnly: true);
foreach (var summary in page.Messages)
{
var message = await client.GetReceivedMessageAsync(
messageId: summary.Id,
mailbox: "support@domain.com");
Console.WriteLine($"{message.ReceivedDateTime}: {message.From} - {message.Subject}");
Console.WriteLine(message.Body?.Content);
}
GetInboxAsync requests newest messages first when reading all messages. Graph
does not guarantee ordering when the unread-only filter is used. pageSize must
be between 1 and 1000. GetReceivedMessageAsync also expands file attachments
and returns their content in FlexiAttachment.Bytes.
Receive notifications for new email
The application must expose a publicly accessible HTTPS webhook. FlexiMail creates and manages the Microsoft Graph subscription; the consuming ASP.NET Core application hosts the endpoint.
const string clientState = "store-this-as-a-secret";
var subscription = await client.SubscribeToInboxAsync(
notificationUrl: "https://api.domain.com/webhooks/fleximail",
clientState: clientState,
mailbox: "support@domain.com",
lifecycleNotificationUrl: "https://api.domain.com/webhooks/fleximail/lifecycle");
// Persist subscription.Id and subscription.ExpirationDateTime.
// Renew it before expiration:
subscription = await client.RenewSubscriptionAsync(subscription.Id);
// Remove it when no longer required:
await client.DeleteSubscriptionAsync(subscription.Id);
Designing the notification endpoint
NotificationUrl receives resource-change notifications. For the subscription
created above, each notification means that a message was created in the
mailbox Inbox. The endpoint should only validate and durably enqueue the event;
email retrieval and business processing should run in a background worker.
Both webhook URLs must implement Microsoft's validation handshake. Graph sends
a POST with a validationToken query parameter, and the endpoint must return
the URL-decoded token with 200 OK, text/plain, and no JSON wrapper within 10
seconds.
Minimal API notification endpoint:
using FlexiMail.Models.Foundations.Subscriptions;
using System.Text.Json;
app.MapPost("/webhooks/fleximail", async (
HttpRequest request,
IMailNotificationQueue notificationQueue,
CancellationToken cancellationToken) =>
{
// Microsoft Graph validates the URL while the subscription is created.
if (request.Query.TryGetValue("validationToken", out var token))
{
return Results.Text(token.ToString(), "text/plain");
}
var notifications = await JsonSerializer.DeserializeAsync<FlexiMailNotificationCollection>(
request.Body,
cancellationToken: cancellationToken);
foreach (var notification in notifications?.Value ?? [])
{
if (!notification.HasClientState(clientState))
{
continue;
}
// Persist to a durable queue. Do not retrieve or process the email here.
await notificationQueue.EnqueueAsync(notification, cancellationToken);
}
return Results.Accepted();
});
IMailNotificationQueue represents an application-owned durable queue, such as
Azure Service Bus, RabbitMQ, Amazon SQS, or a database-backed job queue. A
background worker dequeues the event and retrieves the message:
if (notification.ChangeType == "created" &&
!string.IsNullOrWhiteSpace(notification.ResourceData?.Id))
{
var message = await client.GetReceivedMessageAsync(
notification.ResourceData.Id,
mailbox: "support@domain.com",
cancellationToken);
// Process the email idempotently here.
}
The notification endpoint should:
- Accept only HTTPS requests.
- Complete the validation-token handshake before attempting JSON parsing.
- Deserialize every item in the
valuearray because Graph batches events. - Compare
clientStatewith the secret stored for that subscription and discard mismatches. - Durably enqueue valid events and return
202 Acceptedwithin three seconds. - Return
5xxif the event could not be persisted, allowing Graph to retry. - Process events idempotently because duplicate or retried notifications are
possible. A useful deduplication key combines
SubscriptionId,ChangeType, andResourceData.Id.
Do not trust the mailbox, subscription, or message ID solely because it appears
in the request. Match SubscriptionId to a stored subscription and use the
stored mailbox when retrieving the message. Keep clientState secret and do
not log it.
Designing the lifecycle notification endpoint
LifecycleNotificationUrl receives events about subscription health, not new
emails. Its payload uses the same top-level { "value": [...] } envelope and
contains a lifecycleEvent value. A minimal application DTO is:
using System.Text.Json.Serialization;
public sealed class GraphLifecycleNotificationCollection
{
[JsonPropertyName("value")]
public List<GraphLifecycleNotification> Value { get; set; } = [];
}
public sealed class GraphLifecycleNotification
{
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; }
[JsonPropertyName("subscriptionExpirationDateTime")]
public DateTimeOffset? SubscriptionExpirationDateTime { get; set; }
[JsonPropertyName("clientState")]
public string ClientState { get; set; }
[JsonPropertyName("lifecycleEvent")]
public string LifecycleEvent { get; set; }
}
The lifecycle endpoint follows the same handshake, validation, batching, and queue-first rules:
app.MapPost("/webhooks/fleximail/lifecycle", async (
HttpRequest request,
IMailLifecycleQueue lifecycleQueue,
CancellationToken cancellationToken) =>
{
if (request.Query.TryGetValue("validationToken", out var token))
{
return Results.Text(token.ToString(), "text/plain");
}
var batch = await JsonSerializer.DeserializeAsync<GraphLifecycleNotificationCollection>(
request.Body,
cancellationToken: cancellationToken);
foreach (var notification in batch?.Value ?? [])
{
if (!string.Equals(notification.ClientState, clientState,
StringComparison.Ordinal))
{
continue;
}
await lifecycleQueue.EnqueueAsync(notification, cancellationToken);
}
return Results.Accepted();
});
The lifecycle worker handles each event as follows:
reauthorizationRequired: callRenewSubscriptionAsync(subscriptionId). Renewal also reauthorizes the subscription. Do not concurrently send separate renew and reauthorize requests for the same subscription.subscriptionRemoved: create a replacement withSubscribeToInboxAsync(...), persist its new ID and expiration, and reconcile the Inbox for changes that occurred during the gap.missed: reconcile the Inbox against durable local state. Microsoft Graph delta queries are the preferred large-scale recovery mechanism; FlexiMail does not currently expose delta-query APIs, so consumers must either call Graph directly or rescan a suitable recent Inbox window and deduplicate by message ID.
Subscriptions created by FlexiMail expire after six days. Store the subscription
ID, mailbox, clientState, and expiration in durable storage, and run a scheduled
renewal before expiration even when no lifecycle event was received. Lifecycle
events complement scheduled renewal; they do not replace it.
For both endpoints, Graph considers a notification delivered after a timely 2xx
response. Returning quickly avoids endpoint throttling and dropped events;
durable queues ensure work survives after 202 Accepted is returned.
See Microsoft's documentation for the complete webhook delivery contract and lifecycle-event behavior.
The Entra application needs the Microsoft Graph application permission
Mail.Read with administrator consent. Because this permission can read tenant
mailboxes, administrators should restrict the application's mailbox access in
Exchange Online where appropriate. Mail.Send remains required for sending.
Create a shared mailbox
FlexiMail can provision an Exchange Online shared mailbox by running the
Microsoft-supported New-Mailbox -Shared cmdlet with app-only certificate
authentication. This is a separate administrative client because mailbox
provisioning requires substantially broader permissions than sending or reading
mail.
Prerequisites:
Install PowerShell 7 on the machine running the application.
Install the Exchange Online module for the same user that runs the process:
Install-Module ExchangeOnlineManagement -Scope CurrentUserAdd the Office 365 Exchange Online application permission
Exchange.ManageAsAppto the Entra app and grant administrator consent.For initial setup and manual testing, assign the enterprise application the supported Microsoft Entra Exchange Administrator directory role under Identity > Roles & admins. Do not confuse this with an Azure subscription RBAC role or a Graph mail-access application role.
Exchange Administratoris broad; production deployments should replace it with a tested custom Exchange Online role group containing only the required recipient-creation commands and write scope.Upload the public certificate to the app registration. Install its private certificate in the certificate store of the user running FlexiMail. The configured thumbprint identifies that certificate.
using FlexiMail;
using FlexiMail.Models.Configurations;
using FlexiMail.Models.Foundations.Mailboxes;
var provisioningClient = new FlexiMailboxProvisioningClient(
new ExchangeProvisioningConfigurations
{
AppId = "00000000-0000-0000-0000-000000000000",
Organization = "contoso.onmicrosoft.com",
CertificateThumbprint = "0123456789ABCDEF0123456789ABCDEF01234567",
PowerShellExecutable = "pwsh"
});
var mailbox = await provisioningClient.CreateSharedMailboxAsync(
new FlexiSharedMailboxRequest
{
DisplayName = "Customer Support",
Alias = "support",
PrimarySmtpAddress = "support@contoso.com"
});
Console.WriteLine($"Created {mailbox.PrimarySmtpAddress}");
The operation starts a non-interactive PowerShell process, imports
ExchangeOnlineManagement, connects with the configured app and certificate,
creates the mailbox, returns a typed result, and disconnects. No client secret
or certificate private key is placed in the generated command.
A shared mailbox can use up to 50 GB without its own license. The tenant must still have an Exchange Online subscription, and licensing is required for more than 50 GB, archiving, litigation hold, and certain compliance features. A normal user mailbox cannot be provisioned without an Exchange Online license. See Microsoft's shared-mailbox licensing guidance and app-only Exchange authentication setup.
Send via Microsoft Graph
using FlexiMail;
using FlexiMail.Models.Configurations;
using FlexiMail.Models.Foundations.Bodies;
using FlexiMail.Models.Foundations.Messages;
var configurations = new GraphMailConfigurations
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret",
TenantId = "your-tenant-id",
SenderUserIdOrUpn = "sender@domain.com",
Scopes = ["https://graph.microsoft.com/.default"]
};
var client = new FlexiMailClient(configurations);
await client.SendAndSaveCopyAsync(new FlexiMessage
{
To = ["email@domain.com"],
Subject = "Hello from FlexiGraphService",
Body = new FlexiBody
{
Content = "Graph-powered delivery.",
ContentType = BodyContentType.Html
}
});
Configuration
Example appsettings.json snippet:
{
"ExchangeConfigurations": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"TenantId": "your-tenant-id",
"SmtpAddress": "sender@domain.com",
"Authority": "https://login.microsoftonline.com/{tenantId}",
"Scopes": ["https://outlook.office365.com/.default"]
},
"GraphMailConfigurations": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"TenantId": "your-tenant-id",
"SenderUserIdOrUpn": "sender@domain.com",
"Scopes": ["https://graph.microsoft.com/.default"]
}
}
The Scopes value remains https://graph.microsoft.com/.default; actual
permissions (Mail.Send, Mail.Read) are configured and consented on the Entra
application registration.
Architecture
- Brokers: integrations with Exchange and Graph
- Services: core workflows, including
FlexiGraphServicefor Graph - Models: message, body, and configuration contracts
FlexiMailClient chooses the appropriate service based on the provided
configuration and always saves a copy to Sent Items. Inbox reading and
subscription management are Graph-only; calling them on an Exchange-configured
client throws NotSupportedException.
Contributing
For complete Exchange Online shared-mailbox provisioning setup—including app registration, permissions, PowerShell, certificate creation, and troubleshooting—see GUIDE.md.
- Fork the repository
- Create a branch (
git checkout -b users/your-github-id/feature-name) - Commit (
git commit -m "Add feature") - Push (
git push origin users/your-github-id/feature-name) - Open a Pull Request
License
MIT. See LICENSE.
Contact
For questions: contact@mahdhi.com
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net10.0
- Azure.Identity (>= 1.17.1)
- Microsoft.Extensions.DependencyInjection (>= 10.0.2)
- Microsoft.Graph (>= 5.101.0)
- Xeption (>= 2.8.0)
-
net8.0
- Azure.Identity (>= 1.17.1)
- Microsoft.Exchange.WebServices (>= 2.2.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.2)
- Microsoft.Graph (>= 5.101.0)
- Xeption (>= 2.8.0)
-
net9.0
- Azure.Identity (>= 1.17.1)
- Microsoft.Exchange.WebServices (>= 2.2.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.2)
- Microsoft.Graph (>= 5.101.0)
- Xeption (>= 2.8.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Added Exchange Online shared-mailbox provisioning through app-only certificate authentication and PowerShell 7. Added typed provisioning configuration, request, and result contracts; validation and cancellation support; a guarded manual test scenario; and a complete setup and troubleshooting guide.