Guide

Send Email in C#: MailKit, Graph API, CLI

Microsoft no longer recommends SmtpClient for new .NET code. Compare MailKit over SMTP, the Microsoft Graph SDK, and a CLI subprocess, with runnable C# code and an ASP.NET Core 2FA endpoint.

Written by Hazik Director of Product Management

Reviewed by Qasim Muhammad

VerifiedCLI 3.1.17 · Gmail, Outlook · last tested June 9, 2026

Full documentation for the commands below: nylas email send for the Process.Start pattern, nylas auth login for interactive OAuth, and nylas auth config for key-based headless setup.

Why does Microsoft warn against SmtpClient in C#?

To send email in C# today, skip System.Net.Mail.SmtpClient. Microsoft's own .NET API reference states: “We don't recommend that you use the SmtpClient class for new development because SmtpClient doesn't support many modern protocols. Use MailKit or other libraries instead.”

The class was designed around RFC 2821-era SMTP (the protocol spec was superseded by RFC 5321 in October 2008) and never gained first-class OAuth 2.0 support. That gap bites hardest on Microsoft 365: Microsoft's SMTP AUTH documentation steers client submission toward OAuth and notes that tenants with security defaults enabled have SMTP AUTH disabled outright. Gmail still accepts app passwords on accounts with 2-Step Verification, but plain account passwords are long gone there too. That leaves C# developers with three practical paths: MailKit when you control SMTP credentials, the Microsoft Graph SDK when the mailbox lives in Microsoft 365, and a CLI subprocess when you want one code path across providers.

How do I send email in C# with MailKit?

MailKit is the open-source mail library that Microsoft's SmtpClient documentation points to by name. Install it with dotnet add package MailKit from NuGet, build a MimeMessage, and send over SMTP. Gmail setup takes about 5 minutes: enable 2-Step Verification and generate a 16-character app password. That credential has been mandatory for personal Gmail since May 2022, when Google ended Less Secure Apps for consumer accounts; Workspace held on until the final cutoff hit on May 1, 2025.

The code below connects to Gmail's SMTP server on port 587 with STARTTLS and authenticates with that app password. Daily ceilings apply: a personal address can send 500 messages, a Workspace mailbox 2,000. For Outlook, swap the host to smtp.office365.com — and plan on an OAuth token rather than a password, since Microsoft 365 tenants with security defaults enabled have SMTP AUTH turned off; MailKit handles OAuth via its SASL mechanisms.

using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;

var message = new MimeMessage();
message.From.Add(new MailboxAddress("Your Name", "you@gmail.com"));
message.To.Add(MailboxAddress.Parse("recipient@example.com"));
message.Subject = "Quarterly report";
message.Body = new TextPart("plain") { Text = "See the attached spreadsheet." };

using var client = new SmtpClient(); // MailKit.Net.Smtp, not System.Net.Mail
await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls);
await client.AuthenticateAsync("you@gmail.com", "your-16-char-app-password");
await client.SendAsync(message);
await client.DisconnectAsync(true);

The tradeoff is credential management. App passwords are provider-specific, don't expire on their own, and grant full mailbox access — one leaked 16-character string exposes everything. The MailKit repository documents OAuth 2.0 flows for Gmail and Office 365, but wiring token refresh into a background worker is code you have to own.

How do I send email in C# with the Microsoft Graph SDK?

The Microsoft Graph SDK sends email from Microsoft 365 mailboxes through the sendMail endpoint over HTTPS, with no SMTP involved. You need an Entra ID app registration with the Mail.Send permission, which adds 15+ minutes of portal setup before the first message leaves your machine.

The example below uses the Graph SDK v5 pattern with InteractiveBrowserCredential for the OAuth consent flow, then posts to me/sendMail. Per Microsoft's throttling documentation, the Outlook service allows 10,000 API requests per app per mailbox in a 10-minute window, with a maximum of 4 concurrent requests.

using Azure.Identity;
using Microsoft.Graph;
using Microsoft.Graph.Me.SendMail;
using Microsoft.Graph.Models;

var credential = new InteractiveBrowserCredential(
    new InteractiveBrowserCredentialOptions { ClientId = "<your-app-client-id>" });
var graph = new GraphServiceClient(credential, new[] { "Mail.Send" });

await graph.Me.SendMail.PostAsync(new SendMailPostRequestBody
{
    Message = new Message
    {
        Subject = "Quarterly report",
        Body = new ItemBody { ContentType = BodyType.Text, Content = "See the attached spreadsheet." },
        ToRecipients = new List<Recipient>
        {
            new() { EmailAddress = new EmailAddress { Address = "recipient@example.com" } },
        },
    },
    SaveToSentItems = true,
});

Graph is Microsoft-only. A Gmail or Yahoo sender needs an entirely different SDK, and the access token expires after roughly an hour, so production code depends on the credential class refreshing it silently. For a deeper walkthrough of app registration and permissions, see the Microsoft Graph email quickstart.

How do I send email from C# with a CLI subprocess?

A subprocess call hands SMTP, OAuth token refresh, and MIME encoding to an external binary, so your C# project ships with zero email packages. System.Diagnostics.Process runs nylas email send and captures stdout; the CLI authenticates against Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP from one command. Setup takes about 2 minutes: brew install nylas/nylas-cli/nylas (other methods in the getting started guide), then authenticate once.

The --json flag returns structured output for JsonDocument.Parse, and --yes skips the interactive confirmation so the call works in services and CI. Using ArgumentList instead of a concatenated argument string means recipient addresses and subject lines are passed as discrete arguments — no shell, no injection surface.

using System.Diagnostics;
using System.Text.Json;

static JsonDocument SendEmail(string to, string subject, string body)
{
    var psi = new ProcessStartInfo("nylas") { RedirectStandardOutput = true };
    foreach (var arg in new[] { "email", "send",
        "--to", to, "--subject", subject, "--body", body, "--json", "--yes" })
    {
        psi.ArgumentList.Add(arg);
    }

    using var process = Process.Start(psi)!;
    var stdout = process.StandardOutput.ReadToEnd();
    process.WaitForExit();
    if (process.ExitCode != 0)
        throw new InvalidOperationException($"nylas exited with code {process.ExitCode}");
    return JsonDocument.Parse(stdout);
}

using var result = SendEmail(
    "recipient@example.com", "Quarterly report", "See the attached spreadsheet.");
Console.WriteLine(result.RootElement.GetProperty("id").GetString());

Spawning a process adds roughly 100ms per send compared to a pooled SMTP connection, so MailKit wins for batch jobs over 1,000 messages. For transactional volume (signup confirmations, alerts, OTP codes) the overhead is invisible and switching a sender from Gmail to Outlook requires zero code changes. PowerShell teams replacing Send-MailMessage use the same binary; see replace Send-MailMessage.

How do I send a 2FA code from ASP.NET Core?

An ASP.NET Core 2FA endpoint generates a one-time password, emails it through the subprocess pattern above, and stores the code with an expiry for later verification. RandomNumberGenerator.GetInt32 draws from the OS cryptographic RNG — never System.Random for security codes. A 6-digit OTP gives 1,000,000 combinations, which is safe when paired with a 5-minute expiry and an attempt limit.

The minimal API below maps a POST endpoint that accepts an email address, generates the code, and shells out to the CLI. In testing, the full request (OTP generation plus the subprocess send) completes in well under 1 second. Rate-limit the route (ASP.NET Core 7+ ships AddRateLimiter) so an attacker can't spam codes to arbitrary addresses.

using System.Diagnostics;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
var app = builder.Build();

app.MapPost("/auth/send-otp", async (OtpRequest req, IMemoryCache cache) =>
{
    var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
    cache.Set($"otp:{req.Email}", code, TimeSpan.FromMinutes(5));

    var psi = new ProcessStartInfo("nylas");
    foreach (var arg in new[] { "email", "send",
        "--to", req.Email,
        "--subject", "Your verification code",
        "--body", $"Your code is {code}. It expires in 5 minutes.",
        "--json", "--yes" })
    {
        psi.ArgumentList.Add(arg);
    }

    using var process = Process.Start(psi)!;
    await process.WaitForExitAsync();
    return process.ExitCode == 0 ? Results.Ok() : Results.StatusCode(502);
});

app.Run();

record OtpRequest(string Email);

The same endpoint built on MailKit needs credential storage and rotation; built on Graph it needs an app registration and works only for Microsoft 365 senders. The subprocess version is 30 lines and provider-neutral. Node.js teams get the identical pattern with child_process in the Node.js guide.

Next steps