Skip to content
← All posts
· 7 min read·Emre Yurtbay

Sending Emails Programmatically with the Microsoft Graph API

Send emails from Microsoft 365 via the OAuth2-based Graph API: app registration, token retrieval, working C# code, and a curl example.

Microsoft Graph APIC#.NETOAuth2Microsoft Entra IDExchange OnlineMail.SendAzure IdentitySMTPClient Credentials

Anyone who needs to send emails automatically in a Microsoft 365 environment - whether for notifications, reports, or workflow triggers - has long relied on SMTP AUTH with a username and password. Microsoft, however, is consistently restricting this approach: Basic Authentication was already disabled for most protocols (Outlook, EWS, POP, IMAP, EAS) back in October 2022. SMTP AUTH was initially the exception, but support for Basic Authentication is ending here too: starting in April 2026, OAuth is mandatory for SMTP AUTH, and for existing tenants Basic Auth will be disabled by default by the end of 2026. New tenants no longer get SMTP AUTH with Basic Auth at all. The recommended alternative is the Microsoft Graph API, which is based on OAuth2 and is managed centrally via Microsoft Entra ID.

This article shows the complete path: from app registration through token retrieval to working C# code and a raw HTTP example using curl.


Why Graph instead of SMTP?

SMTP AUTH requires permanently stored credentials of a user or a service account. This brings several drawbacks:

  • Basic Auth is being phased out. For most Exchange Online protocols, Basic Authentication is already disabled; for SMTP AUTH, the shutdown follows in 2026. Anyone building something new today should not rely on Basic Auth in the first place.
  • No granularity. With a single SMTP credential, the application has full access to the mailbox - including reading, deleting, and moving messages.
  • No central auditing. Token-based authentication via Entra ID provides structured sign-in logs and can be secured through Conditional Access. SMTP AUTH, by contrast, is difficult to control and audit.
  • Graph is API-first. The same flow that sends emails can, through the same app registration, also access calendars, Teams messages, or SharePoint.

With the Graph API, the application authenticates as its own identity (application permission), not as a user. This enables a clear separation between human and machine.


App registration in Microsoft Entra ID

Step 1: Create the app and grant permission

In the Microsoft Entra Admin Center, navigate to App registrations and create a new application. Under API permissions, add the following permission:

Type API Permission
Application permission Microsoft Graph Mail.Send

A global administrator must then grant admin consent. Without consent, the permission exists but is not active. The difference between a delegated permission and an application permission is crucial here: a delegated permission acts on behalf of a signed-in user, while an application permission acts on behalf of the app itself - and can therefore access all mailboxes in the tenant without further restriction.

Step 2: Restrict access

Mail.Send as an application permission by default allows the app to send emails from any mailbox in the tenant. This is a significant risk that you should absolutely limit.

Microsoft offers two mechanisms for this:

  • RBAC for Applications - the future-proof approach recommended by Microsoft. It binds the app, via its service principal, to a role (e.g. Mail.Send) and a management scope that defines the accessible mailboxes.
  • Application Access Policy - the older, well-established mechanism. Microsoft has since marked it as legacy and recommends RBAC for Applications for new implementations. For existing environments and as a quickly set up starting point, the Application Access Policy still works.

The Application Access Policy restricts access to the members of a mail-enabled security group. Configuration is done via Exchange Online PowerShell:

# Verbindung herstellen
Connect-ExchangeOnline

# Policy anlegen: Zugriff nur auf Mitglieder der Gruppe erlauben
New-ApplicationAccessPolicy `
    -AppId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
    -PolicyScopeGroupId "noreply-senders@contoso.com" `
    -AccessRight RestrictAccess `
    -Description "Nur Versand aus dedizierten Service-Postfaechern"

# Policy testen
Test-ApplicationAccessPolicy `
    -Identity "service-mailer@contoso.com" `
    -AppId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

PolicyScopeGroupId expects a mail-enabled security group (e.g. its email address). This way, the app sends only from mailboxes that are members of the specified group. Least privilege is thus enforced at the tenant level as well.

Step 3: Client secret or certificate

For app authentication, you need either a client secret (simple, but a rotating shared secret) or a certificate (recommended for production). A certificate can be stored in Azure Key Vault and retrieved via managed identity in the runtime environment - no secret ever ends up in code or in a configuration file.

To get started, the following examples show the secret-based flow. In production, replace ClientSecretCredential with ClientCertificateCredential from the Azure.Identity package.


OAuth2 client credentials flow

The process can be illustrated in four steps:

   (1) POST /oauth2/v2.0/token
   client_id, client_secret, scope, grant_type
+-----------------+ -------------------------------> +-----------------+
|                 |                                  |                 |
|   Ihre App      |                                  |   Entra ID      |
|   (Daemon)      |                                  | Token-Endpoint  |
|                 | <------------------------------- |                 |
+-----------------+   (2) Access Token (JWT)         +-----------------+
        |
        | (3) POST /v1.0/users/{upn}/sendMail
        |     Authorization: Bearer <token>
        v
+-----------------+                                  +-----------------+
|                 | -------------------------------> |                 |
|   Graph API     |       JSON Mail-Payload          | Exchange Online |
|   /sendMail     |                                  | Postfach        |
|                 | <------------------------------- |                 |
+-----------------+   (4) HTTP 202 Accepted          +-----------------+

The token endpoint has the following format:

https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token

The scope for Graph API application permissions is always:

https://graph.microsoft.com/.default

Implementation in C# with the Microsoft Graph SDK

First, install the required packages:

dotnet add package Microsoft.Graph
dotnet add package Azure.Identity

Then the actual code - fully typed with a record type for the configuration:

using Azure.Identity;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.SendMail;

// Konfiguration – in Produktion aus Azure App Configuration / Key Vault laden
var config = new GraphMailConfig(
    TenantId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    ClientId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    ClientSecret: Environment.GetEnvironmentVariable("GRAPH_CLIENT_SECRET")!,
    SenderUpn: "service-mailer@contoso.com"
);

var credential = new ClientSecretCredential(
    config.TenantId,
    config.ClientId,
    config.ClientSecret
);

var graphClient = new GraphServiceClient(credential);

var requestBody = new SendMailPostRequestBody
{
    Message = new Message
    {
        Subject = "Automatisierte Benachrichtigung",
        Body = new ItemBody
        {
            ContentType = BodyType.Html,
            Content = "<h1>Hallo</h1><p>Dies ist eine automatisierte Nachricht.</p>"
        },
        ToRecipients = new List<Recipient>
        {
            new Recipient
            {
                EmailAddress = new EmailAddress
                {
                    Address = "empfaenger@beispiel.de"
                }
            }
        }
    },
    SaveToSentItems = false   // Sent-Items des Service-Accounts nicht befuellen
};

await graphClient
    .Users[config.SenderUpn]
    .SendMail
    .PostAsync(requestBody);

Console.WriteLine("Mail erfolgreich gesendet.");

// Konfigurationsrecord
record GraphMailConfig(
    string TenantId,
    string ClientId,
    string ClientSecret,
    string SenderUpn
);

SaveToSentItems = false is generally sensible for service accounts in order not to needlessly fill up the mailbox. The default value of the Graph API is true; only set the value explicitly if you want to change the behavior - for example to true if you need a send history in the mailbox.


Raw HTTP variant with curl

Anyone who wants to understand the flow without an SDK, or rebuild it in another language, can reach the goal with two curl calls.

Step 1: Get a token

TOKEN=$(curl -s -X POST \
  "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials" \
  | jq -r '.access_token')

Step 2: Send the email

curl -s -X POST \
  "https://graph.microsoft.com/v1.0/users/service-mailer@contoso.com/sendMail" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "subject": "Test via Graph API",
      "body": {
        "contentType": "Text",
        "content": "Hallo aus dem Terminal."
      },
      "toRecipients": [
        {
          "emailAddress": {
            "address": "empfaenger@beispiel.de"
          }
        }
      ]
    },
    "saveToSentItems": false
  }'

An HTTP status of 202 Accepted means that Exchange Online has accepted the email for delivery. Note: this is not a delivery confirmation. The actual processing runs asynchronously in the background and is subject to the limits and throttling of Exchange Online - which corresponds to normal SMTP behavior.


Security notes in brief

  • Certificate instead of secret. Client secrets expire and have to be rotated. A certificate stored in Azure Key Vault can be retrieved via managed identity without a secret ending up in the application configuration.
  • Enforce a mailbox scope. Without restriction, your app has access to all mailboxes. Limit access - preferably via RBAC for Applications, alternatively via an Application Access Policy - to a dedicated service mailbox.
  • Never put secrets in code. Use Azure.Extensions.AspNetCore.Configuration.Secrets or IConfiguration with Key Vault binding. Environment.GetEnvironmentVariable is acceptable for local development, not for production.
  • Token caching. GraphServiceClient with Azure.Identity caches tokens automatically. Do not request a new token for every email.

Practical recommendation

The approach described - an app registration with Mail.Send, restricted to a dedicated service mailbox (via RBAC for Applications or an Application Access Policy), authenticated by a certificate from Azure Key Vault - is the most secure and most easily auditable way to send emails automatically in a Microsoft 365 environment. The additional effort compared to SMTP is one-time and minimal; the gain in governance, security, and maintainability is permanent and considerable.

Do you have questions about the implementation, the certificate strategy, or integration into your existing .NET architecture? Feel free to write to us at info@yurtbay.dev.

Discuss your project