Updated on

To send an email from an ASP.NET Core Web API, we build a MimeMessage with MimeKit, send it through MailKit’s SmtpClient, and keep the SMTP server, port and password in configuration. Attachments go in through BodyBuilder.

We’ll set up the SMTP settings first and send a plain-text email. Then we switch the body to HTML, send asynchronously and, finally, attach uploaded files to the message.


VIDEO: How to Send an Email with Attachments in ASP.NET Core.


To download the source code for this article, you can visit our GitHub repository.

Let’s start.

How Do We Configure SMTP Settings in ASP.NET Core?

We are going to start with a new ASP.NET Core Web API project that uses controllers. After the project creation, we are going to add a .NET class library with the name EmailService and reference it from the main project:

dotnet new webapi --use-controllers -n EmailApp
dotnet new classlib -n EmailService
dotnet add EmailApp reference EmailService

We need the --use-controllers flag because we modify the WeatherForecastController later. Without it, the Web API template generates minimal API endpoints and leaves that controller out.

Let’s add the EmailConfiguration class inside the EmailService project:

public class EmailConfiguration
{
    public string From { get; set; } = string.Empty;
    public string SmtpServer { get; set; } = string.Empty;
    public int Port { get; set; }
    public string UserName { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

This class contains properties needed to configure sending email messages from our application. The string.Empty defaults are there because new projects turn nullable reference types on, and without them every string property raises a warning.

We are going to use the appsettings.json file to populate these properties, so let’s add an EmailConfiguration section to it:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "EmailConfiguration": {
    "From": "[email protected]",
    "SmtpServer": "smtp.gmail.com",
    "Port": 465,
    "Username": "[email protected]"
  },
  "AllowedHosts": "*"
}

We leave only the password out of this section, because appsettings.json goes into source control. In development, we keep the password in user secrets. From the EmailApp folder, we run:

dotnet user-secrets init
dotnet user-secrets set "EmailConfiguration:Password" "<our app password>"

WebApplication.CreateBuilder() loads user secrets in the Development environment and merges them into the same EmailConfiguration section, so the class above gets all five values. In production, the same key comes from an environment variable named EmailConfiguration__Password or from a secret store. We cover the environment variable route in user secrets in ASP.NET Core.

To finish with the configuration process, we are going to modify the Program class, with using EmailService; at the top of the file:

var emailConfig = builder.Configuration
    .GetSection("EmailConfiguration")
    .Get<EmailConfiguration>()
    ?? throw new InvalidOperationException("The EmailConfiguration section is missing.");
builder.Services.AddSingleton(emailConfig);

builder.Services.AddControllers();

So, we extract configuration values from the EmailConfiguration section and register EmailConfiguration as a singleton. If the section is missing, the app stops at startup with a clear message instead of failing on the first email. And that’s all we need to configure our email service.

How Do We Send Through Gmail With an App Password?

Google no longer supports the use of third-party apps or devices which ask you to sign in to your Google Account using only your username and password. So, we have to use a different solution for our application. To do this, we need to enable 2-step verification for our Gmail account first and then we can use the App Password feature to overcome this issue.

So, first we turn on 2-Step Verification for the Google Account we send from, in its settings at myaccount.google.com.

With that in place, we open the app passwords page, type any name for our app and create the password. Google then shows the new password, which its help page calls a 16-digit passcode. That is the value we store with dotnet user-secrets set, never our personal Gmail password.

Google’s own help page is blunt about the feature: “App passwords aren’t recommended and are unnecessary in most cases.” For a demo against a Gmail inbox, it is still the shortest way in. For a production app, a transactional email service with an API key is the more common route, and we show one in the SendGrid API in C#.

With this in place, we are enabled again to send emails with our third-party apps.

Should We Use MailKit or System.Net.Mail SmtpClient?

We use MailKit. System.Net.Mail.SmtpClient still ships with .NET 10, but Microsoft no longer recommends it for new code, and its API reference sends readers to MailKit instead.

MailKit is an open-source mail client library for .NET, and it sits on top of MimeKit. MimeKit builds the message: the MimeMessage, its addresses, an HTML body and the attachments. MailKit’s own SmtpClient opens the connection, negotiates TLS, logs in and sends that message.

The two SmtpClient classes share a name, so the namespace decides which one we get. Ours always comes from MailKit.Net.Smtp.

We install the MailKit package itself. Earlier versions of this article installed NETCore.MailKit, a third-party wrapper. Version 2.0.2 of that wrapper resolves MailKit 2.0.1 and MimeKit 2.0.1, from 2018, and NuGet now flags both with security advisories published in 2026. The fixes landed in MailKit 4.16.0 and MimeKit 4.15.1.

Microsoft’s API reference for System.Net.Mail.SmtpClient puts it this way: “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.”

If we’d rather not deal with SMTP clients at all, FluentEmail wraps SMTP, SendGrid and other senders behind one API.

Add the MailKit Library

Before starting any other operation in our project, we have to add the MailKit package to the EmailService project. From the EmailService folder, we run:

dotnet add package MailKit

MailKit brings MimeKit with it, so this one package is all we need to send the email.

At the time of writing, a build also warns about System.Security.Cryptography.Pkcs 10.0.0, which MimeKit pulls in and NuGet flags with two advisories. Adding the patched version directly clears the warnings:

dotnet add package System.Security.Cryptography.Pkcs --version 10.0.13

The Web API template adds one more warning of its own, for Microsoft.OpenApi 2.0.0, which comes in through Microsoft.AspNetCore.OpenApi. Running dotnet add package Microsoft.AspNetCore.OpenApi --version 10.0.13 from the EmailApp folder clears it.

The imports we’ll be using are:

using MailKit.Net.Smtp;
using MimeKit;

Send a Test Email

Next, in the same project, we are going to create a Message class:

public class Message
{
    public List<MailboxAddress> To { get; set; }
    public string Subject { get; set; }
    public string Content { get; set; }

    public Message(IEnumerable<string> to, string subject, string content)
    {
        To = new List<MailboxAddress>();

        To.AddRange(to.Select(x => MailboxAddress.Parse(x)));
        Subject = subject;
        Content = content;
    }
}

We are going to use this class to set the data related to our email recipients, subject, and content.

MailboxAddress.Parse() turns each string into an address. It throws a ParseException on an empty or malformed string, but it accepts a bare word such as not-an-email, so it is no replacement for validating an email address before we send.

Then, let’s create a new interface:

public interface IEmailSender
{
    void SendEmail(Message message);
}

And a class that implements this interface:

public class EmailSender : IEmailSender
{
    private readonly EmailConfiguration _emailConfig;

    public EmailSender(EmailConfiguration emailConfig)
    {
        _emailConfig = emailConfig;
    }

    public void SendEmail(Message message)
    {
        var emailMessage = CreateEmailMessage(message);

        Send(emailMessage);
    }
}

As you can see, we inject email configuration into EmailSender class and then we call two different methods to create an email message and to send the email respectively. Now, let’s implement those two missing methods:

private MimeMessage CreateEmailMessage(Message message)
{
    var emailMessage = new MimeMessage();
    emailMessage.From.Add(MailboxAddress.Parse(_emailConfig.From));
    emailMessage.To.AddRange(message.To);
    emailMessage.Subject = message.Subject;
    emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Text) { Text = message.Content };

    return emailMessage;
}

private void Send(MimeMessage mailMessage)
{
    using var client = new SmtpClient();

    client.Connect(_emailConfig.SmtpServer, _emailConfig.Port, true);
    client.Authenticate(_emailConfig.UserName, _emailConfig.Password);

    client.Send(mailMessage);
    client.Disconnect(true);
}

We use the first method to create an object of type MimeMessage and to configure the required properties. Then, we pass that object to the second method and use the SmtpClient class to connect to the email server, authenticate and send the email.

The true argument asks for an encrypted connection from the first byte, which is what port 465 expects. The using declaration disposes the client even when connecting or sending throws, and the exception still reaches our caller. That’s where we would log it.

Just pay attention that the SmtpClient class here comes from the MailKit.Net.Smtp namespace, not from System.Net.Mail.

Now, we have to register this service in the Program class:

builder.Services.AddScoped<IEmailSender, EmailSender>();

We have one more step to do. Let’s inject IEmailSender into the WeatherForecastController, with using EmailService; at the top of the file:

private readonly IEmailSender _emailSender;

public WeatherForecastController(IEmailSender emailSender)
{
    _emailSender = emailSender;
}

And modify the Get action:

[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
    var message = new Message(["[email protected]"], "Test email", "This is the content from our email.");
    _emailSender.SendEmail(message);

    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

That’s it. Let’s test this now:

Postman request sync

We get a 200 OK response. So, let’s check the source email server:

Email server source

And let’s check the destination server:

Email server destination - Send Email

Excellent. Everything works as expected.

Using HTML in the Email Body

In the previous example, we have been using Plain Text as a body format. But we can use HTML as well with a few modifications to our code.

So, all we have to do is to modify the CreateEmailMessage method:

private MimeMessage CreateEmailMessage(Message message)
{
    var emailMessage = new MimeMessage();
    emailMessage.From.Add(MailboxAddress.Parse(_emailConfig.From));
    emailMessage.To.AddRange(message.To);
    emailMessage.Subject = message.Subject;
    emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Html) { Text = $"<h2 style='color:red;'>{message.Content}</h2>" };

    return emailMessage;
}

And that’s all it takes. A simple change of the body text format and the content itself.

We can try sending the same request one more time from Postman:

Html body for the email message

It doesn’t get any easier than that.

How Do We Send the Email Asynchronously?

If we want to send email messages asynchronously, we have to make some changes to our project. If you are not familiar with the asynchronous programming in ASP.NET Core Web API, we strongly recommend reading our article about async and await in ASP.NET Core.

In a Web API, this is the version to use. While we wait for the SMTP server, the request thread goes back to the pool and serves other requests.

Let’s start with the interface modification:

public interface IEmailSender
{
    void SendEmail(Message message);
    Task SendEmailAsync(Message message);
}

Next, let’s modify the EmailSender class:

public async Task SendEmailAsync(Message message)
{
    var mailMessage = CreateEmailMessage(message);

    await SendAsync(mailMessage);
}

As you can see, we use the same method to create an email message but a different method to send that email. So, we have to implement that one:

private async Task SendAsync(MimeMessage mailMessage)
{
    using var client = new SmtpClient();

    await client.ConnectAsync(_emailConfig.SmtpServer, _emailConfig.Port, true);
    await client.AuthenticateAsync(_emailConfig.UserName, _emailConfig.Password);

    await client.SendAsync(mailMessage);
    await client.DisconnectAsync(true);
}

Finally, we have to modify the Get action:

[HttpGet(Name = "GetWeatherForecast")]
public async Task<IEnumerable<WeatherForecast>> Get()
{
    var message = new Message(["[email protected]"], "Test email async", "This is the content from our async email.");
    await _emailSender.SendEmailAsync(message);

    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

Awesome. Now let’s try this out with the same request from Postman:

Async email message

How Do We Attach Uploaded Files to the Email?

To attach files, we read them from a multipart/form-data request as an IFormFileCollection and add each one to MimeKit’s BodyBuilder.

The controller action takes the files from Request.Form.Files. The Message class carries them to EmailSender in a new Attachments property. There, CreateEmailMessage() copies each file into a byte array and calls bodyBuilder.Attachments.Add() with the file name and the content type the client sent. ToMessageBody() then builds one multipart message, with the HTML part first and one part per file.

IFormFileCollection belongs to ASP.NET Core, so the EmailService class library needs a framework reference to Microsoft.AspNetCore.App. A plain class library doesn’t have one, and without it the code doesn’t compile.

We also have to plan for size. By default, Kestrel rejects any request body over 30,000,000 bytes at the moment our action reads the form, and raising the FormOptions limits doesn’t change that.

So, let’s start by adding a new POST action to our controller:

[HttpPost]
public async Task<IEnumerable<WeatherForecast>> Post()
{
    var files = Request.Form.Files.Any() ? Request.Form.Files : new FormFileCollection();

    var message = new Message(["[email protected]"], "Test mail with Attachments", "This is the content from our mail with attachments.", files);
    await _emailSender.SendEmailAsync(message);

    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

This action is almost the same as the previous one, but it has some important modifications. The first thing to notice is that this is a POST action. The second change is the part where we extract files from the request, if any, or return an empty file collection. The third change is related to the Message constructor, it now accepts an additional parameter.

Of course, we have to modify the Message class:

using Microsoft.AspNetCore.Http;
using MimeKit;

public class Message
{
    public List<MailboxAddress> To { get; set; }
    public string Subject { get; set; }
    public string Content { get; set; }

    public IFormFileCollection? Attachments { get; set; }

    public Message(IEnumerable<string> to, string subject, string content, IFormFileCollection? attachments)
    {
        To = new List<MailboxAddress>();

        To.AddRange(to.Select(x => MailboxAddress.Parse(x)));
        Subject = subject;
        Content = content;
        Attachments = attachments;
    }
}

As you can see, we’ve added the additional IFormFileCollection parameter.

IFormFileCollection comes from ASP.NET Core, so the EmailService project needs a framework reference before this compiles. Let’s add it to EmailService.csproj:

<ItemGroup>
  <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

Now, let’s move on to the EmailSender class:

private MimeMessage CreateEmailMessage(Message message)
{
    var emailMessage = new MimeMessage();
    emailMessage.From.Add(MailboxAddress.Parse(_emailConfig.From));
    emailMessage.To.AddRange(message.To);
    emailMessage.Subject = message.Subject;

    var bodyBuilder = new BodyBuilder { HtmlBody = $"<h2 style='color:red;'>{message.Content}</h2>" };

    if (message.Attachments != null && message.Attachments.Any())
    {
        byte[] fileBytes;
        foreach (var attachment in message.Attachments)
        {
            using (var ms = new MemoryStream())
            {
                attachment.CopyTo(ms);
                fileBytes = ms.ToArray();
            }

            bodyBuilder.Attachments.Add(attachment.FileName, fileBytes, ContentType.Parse(attachment.ContentType));
        }
    }

    emailMessage.Body = bodyBuilder.ToMessageBody();
    return emailMessage;
}

We now use the BodyBuilder class to create a body for the email message. Additionally, we check for the attachment files and if they exist, we convert each of them to the byte array and add it to the Attachments part from the bodyBuilder object. Finally, we convert the bodyBuilder object to the message body and return that message.

Now, before we use Postman to send a request, we are going to add the FormOptions configuration to the Program class, with using Microsoft.AspNetCore.Http.Features; at the top of the file:

builder.Services.Configure<FormOptions>(o =>
{
    o.ValueLengthLimit = int.MaxValue;
    o.MultipartBodyLengthLimit = int.MaxValue;
    o.MemoryBufferThreshold = int.MaxValue;
});

With the FormOptions configuration, we set the limit for its different properties to the maximum value.

That’s fine for a demo, with two caveats. Kestrel has its own request body limit of 30,000,000 bytes, and FormOptions doesn’t lift it: when we posted a 31 MB file to this sample, Kestrel answered 413 with “Request body too large.” And MemoryBufferThreshold = int.MaxValue keeps every uploaded file in memory. For real limits, uploading large files in ASP.NET Core shows how to set them per endpoint.

One more thing. We have to modify the call to the Message constructor in the Get action because now it accepts four parameters.

var message = new Message(["[email protected]"],
    "Test email async",
    "This is the content from our async email.",
    null);

To prepare a request in Postman, we have to choose the POST method option and use the form-data options to send files with the request:

Send Email with attachment Postman

And let’s inspect our email:

Attachments email

Excellent work.

What Breaks When We Send Email From ASP.NET Core?

Most failures show up the first time we run the app against a real server. These are the ones we’d check first:

  • Gmail rejects the login. We used the account password instead of an app password, or 2-Step Verification is off.
  • There is no app passwords option in the Google Account. Google lists three reasons: 2-Step Verification set up with security keys only, a work, school or other organization account, or Advanced Protection on the account.
  • The connection fails with SslHandshakeException. We passed true for a port that expects a plain connection upgraded with STARTTLS, usually 587. For that port, we pass SecureSocketOptions.StartTls from MailKit.Security instead of true.
  • A large upload returns 413. That is Kestrel’s 30,000,000-byte request limit, which sits in front of FormOptions.
  • A recipient bounces although Parse() accepted it. MailboxAddress.Parse() accepts strings without a domain, so we validate addresses before we build the message.

Conclusion

We did a great job here. Now we have a fully functional Web API service that we can use in our projects to send email messages using C#.

So to sum up, we’ve learned:

  • How to create a basic SMTP configuration
  • How to keep the SMTP password out of appsettings.json with user secrets
  • The way to send email messages with plain text or an HTML body
  • How to send email messages asynchronously
  • How to include attachments in an email message

We use this same EmailSender in email confirmation with ASP.NET Core Identity, where it sends the confirmation link to a new user.

Tested with .NET 10.0.10 and MailKit 4.18.1.