FlexiMail 0.3.0

dotnet add package FlexiMail --version 0.3.0
                    
NuGet\Install-Package FlexiMail -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FlexiMail" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FlexiMail" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="FlexiMail" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FlexiMail --version 0.3.0
                    
#r "nuget: FlexiMail, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FlexiMail@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FlexiMail&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=FlexiMail&version=0.3.0
                    
Install as a Cake Tool

<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.

Nuget Nuget .NET 8

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
  • FlexiGraphService for 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 FlexiMailClient is compiled only for net8.0 and net9.0. When targeting net10.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:

  1. Accept only HTTPS requests.
  2. Complete the validation-token handshake before attempting JSON parsing.
  3. Deserialize every item in the value array because Graph batches events.
  4. Compare clientState with the secret stored for that subscription and discard mismatches.
  5. Durably enqueue valid events and return 202 Accepted within three seconds.
  6. Return 5xx if the event could not be persisted, allowing Graph to retry.
  7. Process events idempotently because duplicate or retried notifications are possible. A useful deduplication key combines SubscriptionId, ChangeType, and ResourceData.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: call RenewSubscriptionAsync(subscriptionId). Renewal also reauthorizes the subscription. Do not concurrently send separate renew and reauthorize requests for the same subscription.
  • subscriptionRemoved: create a replacement with SubscribeToInboxAsync(...), 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:

  1. Install PowerShell 7 on the machine running the application.

  2. Install the Exchange Online module for the same user that runs the process:

    Install-Module ExchangeOnlineManagement -Scope CurrentUser
    
  3. Add the Office 365 Exchange Online application permission Exchange.ManageAsApp to the Entra app and grant administrator consent.

  4. 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 Administrator is broad; production deployments should replace it with a tested custom Exchange Online role group containing only the required recipient-creation commands and write scope.

  5. 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 FlexiGraphService for 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.

  1. Fork the repository
  2. Create a branch (git checkout -b users/your-github-id/feature-name)
  3. Commit (git commit -m "Add feature")
  4. Push (git push origin users/your-github-id/feature-name)
  5. Open a Pull Request

License

MIT. See LICENSE.

Contact

For questions: contact@mahdhi.com

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 120 7/20/2026
0.2.0 87 7/20/2026
0.1.6 556 1/29/2026
0.1.5 124 1/29/2026
0.1.4 126 1/29/2026
0.1.3 122 1/29/2026
0.1.2 133 1/29/2026
0.1.1 125 1/29/2026
0.1.0 704 8/11/2024

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.