DEV Community

Cover image for How to Bulk Email with C# and .NET: Zero to Hero
Niels Swimburger.NET 🍔 for Twilio

Posted on • Originally published at twilio.com on

How to Bulk Email with C# and .NET: Zero to Hero

This blog post was written for Twilio and originally published at the Twilio blog.

In a previous tutorial, I shared how you can send individual emails using the SendGrid API. This use case is perfect for transactional emails where you send an email to a single recipient or a small number of recipients. But what if you need to send emails to a very large audience? Well, in this tutorial you'll learn how to send bulk emails using the SendGrid API and C# .NET.

If you're not familiar with sending transactional emails using .NET, I advise you go through the previous post about sending emails with C# and SendGrid first.

Prerequisites

Here’s what you will need to follow along:

Many email services like Gmail and Outlook allow you to put a + sign and any string before the @ symbol like this youremail+test123@gmail.com. Emails sent to this type of address will still be delivered to the original email address. This feature is called Subaddressing. By using subaddressing, you will be able to test sending multiple emails to "different" addresses, however, email services will still prevent large amount of emails from coming through even when using subaddressing.

You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue, if you run into problems.

Send bulk email with C# .NET

As is common in software development, there are many solutions to a given problem, and the best solution depends on your use case; the same goes for sending email in bulk. This tutorial will walk you through all the different ways of sending bulk email, and you'll learn which to use for your specific use case.

Setup

For this tutorial, I have provided a starter project. Open a shell and run the following commands to clone the project and navigate to the project folder:

git clone https://github.com/Swimburger/BulkEmail.git --branch start
cd BulkEmail
Enter fullscreen mode Exit fullscreen mode

The project is a console application which currently only sends one email.

The console app has the following C# files:

  • Program.cs: This is the starting point of the application, it will configure everything and then send the emails.
  • SenderOptions.cs: This file has the SenderOptions class which is used to store some configuration about the SendGrid Email Sender.
  • SubscriberRepository.cs: This file has the SubscriberRepository class which is responsible for returning your imaginary subscribers, represented as Person objects. The Person class is provided by the Bogus library and the subscribers are fake data generated using the same library, for testing purposes. Let's pretend this class retrieves real data from a SQL database as it would look exactly the same from the outside.

Feel free to take a look at these C# files, but don't worry about them, because the only important C# file is EmailSender.cs. This file has the EmailSender class which you will update so that it sends bulk email.

This project does need some configuration which you will store as user secrets. Run the following commands:

dotnet user-secrets set SendGridApiKey [SENDGRID_API_KEY]
dotnet user-secrets set Sender:Email [SENDER_EMAIL]
dotnet user-secrets set Sender:Name '[SENDER_NAME]'
dotnet user-secrets set ToEmailTemplate '[YOUR_EMAIL_TEMPLATE]'
Enter fullscreen mode Exit fullscreen mode

Replace:

  • [SENDGRID_API_KEY] with the API key you created in SendGrid,
  • [SENDER_EMAIL] with your Sender email address that you verified in SendGrid,
  • [SENDER_NAME] with the name you want the recipients to see,
  • [YOUR_EMAIL_TEMPLATE] with the email address you use to test. If you're using subaddressing, use this format: youremail+{0}@gmail.com. {0} will be replaced by the program with unique numbers. If you're not using subaddressing, use your email address without any special modifications.

Now your project should be ready. Try it out by running the project using this command:

dotnet run --SubscriberCount 5
Enter fullscreen mode Exit fullscreen mode

Even though you specified 5 subscribers, you should only receive one email in your inbox. That's because the EmailSender class only sends one email at the moment.

Let's take a look at the EmailSender.cs file:

using System.Text.Encodings.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace BulkEmail;

public class EmailSender
{
    private readonly SubscriberRepository subscriberRepository;
    private readonly ISendGridClient sendGridClient;
    private readonly ILogger<EmailSender> logger;
    private readonly HtmlEncoder htmlEncoder;
    private readonly SenderOptions sender;

    public EmailSender(
        SubscriberRepository subscriberRepository,
        ISendGridClient sendGridClient,
        IOptions<SenderOptions> senderOptions,
        ILogger<EmailSender> logger,
        HtmlEncoder htmlEncoder
    )
    {
        this.subscriberRepository = subscriberRepository;
        this.sendGridClient = sendGridClient;
        this.logger = logger;
        this.htmlEncoder = htmlEncoder;
        this.sender = senderOptions.Value;
    }

    public async Task SendToSubscribers()
    {
        var subscribers = subscriberRepository.GetAll();
        var subscriber = subscribers.First();

        var message = new SendGridMessage
        {
            From = new EmailAddress(sender.Email, sender.Name),
            Subject = "Ahoy matey!",
            HtmlContent = "Welcome aboard <b>friend</b> ⚓️"️,
        };
        message.AddTo(new EmailAddress(subscriber.Email, subscriber.FullName));

        var response = await sendGridClient.SendEmailAsync(message);
        if (response.IsSuccessStatusCode) logger.LogInformation("Email queued");
        else logger.LogError("Email not queued");
    }
}
Enter fullscreen mode Exit fullscreen mode

The EmailSender receives a bunch of parameters via its constructor and stores them into fields. Some of these are unused right now but will be used later. The SendToSubscribers method is responsible for sending an email to all subscribers coming from the SubscriberRepository, but currently it only grabs the first subscriber and stores it in the subscriber variable.

The SendToSubscribers method then creates a SendGridMessage from your configured sender and adds the subscriber as the recipient. Next, the SendGridMessage is passed into the SendEmailAsync method which sends the email to the SendGrid API. The SendGrid API will queue the email and return an HTTP success status code in the response if successful. Lastly, the method logs whether the email was queued or not.

Currently, the program only emails one subscriber, however, the goal is to send an email to all subscribers. Let's fix that!

Bulk email using loops

The first solution is to wrap the email code in a loop. In this example, you can use a foreach -loop like this:

var subscribers = subscriberRepository.GetAll();
foreach (var subscriber in subscribers)
{
    var message = new SendGridMessage
    {
        From = new EmailAddress(sender.Email, sender.Name),
        Subject = "Ahoy matey!",
        HtmlContent = "Welcome aboard <b>friend</b> ⚓️"️,
    };

    message.AddTo(new EmailAddress(subscriber.Email, subscriber.FullName));

    var response = await sendGridClient.SendEmailAsync(message);
    if (response.IsSuccessStatusCode) logger.LogInformation("Email queued");
    else logger.LogError("Email not queued");
}
Enter fullscreen mode Exit fullscreen mode

Replace lines 34 to 47 in the EmailSender.cs file with the above code and save the file. Use the following command to try it out with 5 subscribers, or however many you'd like to spam your own inbox with:

dotnet run --SubscriberCount 5
Enter fullscreen mode Exit fullscreen mode

This is the easiest and most flexible solution, however, it is also the least performant solution.

You could further optimize this solution using parallelization or using Task.WhenAll, however, keep in mind that you can send a maximum of 10,000 API requests per second as noted in the v3 Mail Send API FAQ.

Customize the email per recipient

When you send emails in bulk by submitting a SendGridMessage for every recipient, you have complete control over every email. To customize the subject and email body, you can use any means necessary.

Here's an example that uses string interpolation to customize the subject and email body:

var message = new SendGridMessage
{
    From = new EmailAddress(sender.Email, sender.Name),
    Subject = $"Ahoy {subscriber.FirstName}!",
    HtmlContent = $"Welcome aboard <b>{htmlEncoder.Encode(subscriber.FullName)}</b> ⚓️"️
};
Enter fullscreen mode Exit fullscreen mode

When you build HTML templates and embed user input, your application may become vulnerable to HTML injection attacks. To prevent HTML injection attacks, always encode user input. In the example above the subscriber's full name is HTML encoded using the HtmlEncoder.Encode method. For more details, read How to prevent email HTML injection in C# and .NET.

This works fine for a proof of concept, but in a real application you could use a template engine to generate your HTML. Check out this article about rendering emails using Razor templates to learn more.

Bulk email using Personalizations

The loop solution sends a new API request for every subscriber, but there is actually a way to send many emails using a single API request.

The SendGridMessage class has a property called Personalizations which is of type List<Personalization>. Using these personalizations, you can customize the message for specific recipients using properties like From, Tos, Ccs, Bccs, Subject, etc. However, you cannot override the TextContent or HtmlContent property from the SendGridMessage.

Take a look at this example:

new SendGridMessage
{
    From = new EmailAddress(sender.Email, sender.Name),
    Subject = "Ahoy matey!",
    HtmlContent = "Welcome aboard <b>friend</b> ⚓️"️,
    Personalizations = new List<Personalization>
    {
        new Personalization
        {
            Tos = new List<EmailAddress>
            {
                new EmailAddress("jon@localhost", "Jon")
            }
        },
        new Personalization
        {
            Tos = new List<EmailAddress>
            {
                new EmailAddress("jill@localhost", "Jill"),
                new EmailAddress("jane@localhost", "Jane")
            },
            Subject = "Hello world!"
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

The SendGridMessage in the snippet above will send 2 emails because there are 2 personalizations. One email with the subject "Ahoy matey!" to jon@localhost, and 1 email with the subject "Hello world!" to jill@localhost and jane@localhost.

When you add multiple email addresses to the Tos property, each recipient can see and reply to all the other recipients. So Jill and Jane in the example above will be able to see and reply to each other. To avoid this you can create separate Personalization objects for each recipient. In fact, when you previously used the SendGridMessage.AddTo method, the method created a personalization for you.

You can have up to 1,000 recipients per SendGridMessage. Tos, Ccs, and Bccs across all personalizations all add up to the 1,000 limit. The maximum number of Personalization objects per SendGridMessage is also 1,000.

Since you can only have 1,000 recipients per message, you'll need to paginate over the subscribers. There is a GetByPage(int pageSize, int pageIndex) method in the SubscriberRepository to help with pagination. The following code will paginate over all subscribers by 1,000 subscribers at a time.

var pageSize = 1_000;
var subscriberCount = subscriberRepository.Count();
var amountOfPages = (int) Math.Ceiling((double) subscriberCount / pageSize);

for (var pageIndex = 0; pageIndex < amountOfPages; pageIndex++)
{
    var subscribers = subscriberRepository.GetByPage(pageSize, pageIndex);
    ...
}
Enter fullscreen mode Exit fullscreen mode

Even if you weren't limited by 1,000 recipients per message, it would still be a good idea to paginate over the subscribers to reduce the memory usage of your application. As the number of subscribers increases, your application would run out of memory when loading all subscribers into memory at once.

Now that you have your subscribers, you can create a Personalization object for every one of them. Here's how you can do this using a LINQ query:

var message = new SendGridMessage
{
    From = new EmailAddress(sender.Email, sender.Name),
    Subject = "Ahoy matey!",
    HtmlContent = "Welcome aboard <b>friend</b> ⚓️"️,

    // max 1000 Personalizations
    Personalizations = subscribers.Select(s => new Personalization
    {
        Tos = new List<EmailAddress> {new EmailAddress(s.Email, s.FullName)},
    }).ToList(),
};
Enter fullscreen mode Exit fullscreen mode

After putting all the previous code snippets together, your SendToSubscribers method should look like this:

public async Task SendToSubscribers()
{
    var pageSize = 1_000;
    var subscriberCount = subscriberRepository.Count();
    var amountOfPages = (int) Math.Ceiling((double) subscriberCount / pageSize);

    for (var pageIndex = 0; pageIndex < amountOfPages; pageIndex++)
    {
        var subscribers = subscriberRepository.GetByPage(pageSize, pageIndex);

        var message = new SendGridMessage
        {
            From = new EmailAddress(sender.Email, sender.Name),
            Subject = "Ahoy matey!",
            HtmlContent = "Welcome aboard <b>friend</b> ⚓️"️,

            // max 1000 Personalizations
            Personalizations = subscribers.Select(s => new Personalization
            {
                Tos = new List<EmailAddress> {new EmailAddress(s.Email, s.FullName)},
            }).ToList(),
        };

        var response = await sendGridClient.SendEmailAsync(message);
        if (response.IsSuccessStatusCode) logger.LogInformation("Email queued");
        else logger.LogError("Email not queued");
    }
}
Enter fullscreen mode Exit fullscreen mode

You can lower the pageSize number to test pagination without sending 1,000 emails to your inbox.

For example, for testing purposes, you could set the pageSize to 5 and the SubscriberCount to 20:

dotnet run --SubscriberCount 20
Enter fullscreen mode Exit fullscreen mode

Customize the email per recipient

As I mentioned before, you can override the email subject in a Personalization object, but not the HtmlContent or TextContent. However, you can still customize the email body per recipient using Substitution Tags.

First, add substitution tags to your content like this:

HtmlContent = "Welcome aboard <b>-FullName-</b> ⚓️"️
Enter fullscreen mode Exit fullscreen mode

Then, add substitutions to your Personalization objects like this:

new Personalization
{
    ...
    // Substitutions data is max 10,000 bytes per Personalization object
    Substitutions = new Dictionary<string, string>
    {
        {"-FullName-", htmlEncoder.Encode(s.FullName)}
    }
}
Enter fullscreen mode Exit fullscreen mode

The substitution tag is decorated with dashes (-) in this example, however, you can use any type of decoration characters, as long as the substitution tags in the HtmlContent and TextContent match with the keys of the Substitutions dictionary.

You can also use these substitution tags inside of your email subject. Putting this together, you can send personalized bulk emails like this:

var message = new SendGridMessage
{
    From = new EmailAddress(sender.Email, sender.Name),
    Subject = "Ahoy -FirstName_Raw-!",
    HtmlContent = "Welcome aboard <b>-FullName-</b> ⚓️"️,

    // max 1000 Personalizations
    Personalizations = subscribers.Select(s => new Personalization
    {
        Tos = new List<EmailAddress> {new EmailAddress(s.Email, s.FullName)},
        // Substitutions data is max 10,000 bytes per Personalization object
        Substitutions = new Dictionary<string, string>
        {
            {"-FirstName_Raw-", s.FirstName},
            {"-FullName-", htmlEncoder.Encode(s.FullName)}
        }
    }).ToList(),
};
Enter fullscreen mode Exit fullscreen mode

I chose to suffix the first name substitution tag with _Raw to indicate to myself and others that this variable is not HTML encoded and should not be used in HTML content, to avoid HTML injection.

Send bulk email using Dynamic Email Templates

Thus far, you provided the email subject and body from code, however, you can also use Dynamic Email Templates. You can create these templates using the SendGrid UI or API, and then instead of specifying the subject and content in your SendGridMessage, you set the ID of your template to the SendGridMessage.TemplateId property. Dynamic Email Templates use the Handlebars templating language, which allows you to do variable substitution, conditional rendering, looping, and more.

Implementing Dynamic Email Templates is out of the scope of this tutorial, however, here is what your SendGridMessage would look like to achieve the same result as before:

var message = new SendGridMessage
{
    From = new EmailAddress(sender.Email, sender.Name),
    TemplateId = "d-0a664e681ed14d76bd452637a15b20ab",
    Personalizations = subscribers.Select(s => new Personalization
    {
        Tos = new List<EmailAddress> {new EmailAddress(s.Email, s.FullName)},
        TemplateData = new
        {
            FirstName = s.FirstName,
            FullName = s.FullName
        }
    }).ToList(),
};

Enter fullscreen mode Exit fullscreen mode

Instead of using the Substitutions property on your Personalization, you set the TemplateData property with an object. The Dynamic Email Template will be able to access all the data from the TemplateData object.

Want an in depth guide? Read this article on how to Send Emails with C#, Handlebars templating, and Dynamic Email Templates.

Since Dynamic Email Templates use the same SendGridMessage and Personalization classes, everything you learned about bulk email so far also applies here.

Send bulk email using Single Sends in SendGrid Marketing Campaigns

SendGrid Marketing Campaigns are also out of scope for this tutorial, but for the sake of completeness, I will lightly touch on it. With SendGrid Marketing Campaigns you can create signup forms, manage contacts, organize contacts into lists and segments, create email designs, and more.

And of course, you can send emails to those contacts stored in SendGrid.

You can manage all of the previous features using the SendGrid UI or API. For example, you can manage contacts using the SendGrid UI or programmatically via the API, and you can also send emails to your contacts, lists, and segments using the Single Send API. Since the contacts are all stored in SendGrid, you don't have to worry about any looping and paginating.

With the Single Send API you can either provide the email body via the API or you can specify the ID of the email design stored in SendGrid. For either option, you cannot pass on data through the API to use in the email template. However, the templates have access to the data stored on the contacts. Once you created the Single Send, you can schedule the Single Send to be sent "now" or up to 72 hours from now.

Schedule your bulk email

When you're sending a lot of emails, SendGrid recommends scheduling them to be sent in the near future instead of sending them immediately. Quoting from the SendGrid docs:

This technique allows for a more efficient way to distribute large email requests and can improve overall mail delivery time performance. This functionality:

  • Improves efficiency of processing and distributing large volumes of email.
  • Reduces email pre-processing time.
  • Enables you to time email arrival to increase open rates.
  • Is available for free to all SendGrid customers.

To schedule emails you can set the SendAt property on the SendGridMessage or Personalization object. The SendAt property is a long representing a Unix timestamp.

To calculate this Unix timestamp, you can use the DateTimeOffset.ToUnixTimeSeconds() method.

For example, this will generate the long timestamp for 5 minutes from now:

DateTimeOffset.Now.AddMinutes(5).ToUnixTimeSeconds()
Enter fullscreen mode Exit fullscreen mode

If you're using DateTime instead of DateTimeOffset, you can implicitly and explicitly cast a DateTime to a DateTimeOffset, like this:

DateTimeOffset fiveMinutesFromNow = DateTime.Now.AddMinutes(5); // implicit cast
fiveMinutesFromNow = (DateTimeOffset) DateTime.Now.AddMinutes(5); // explicit cast
Enter fullscreen mode Exit fullscreen mode

Check out this Microsoft doc on converting between DateTime and DateTimeOffset to learn more.

Here's the SendToSubscribers method for sending the Bulk Email 5 minutes from now:

public async Task SendToSubscribers()
{
    var sendAt = DateTimeOffset.Now.AddMinutes(5).ToUnixTimeSeconds();

    var pageSize = 1_000;
    var subscriberCount = subscriberRepository.Count();
    var amountOfPages = (int) Math.Ceiling((double) subscriberCount / pageSize);

    for (var pageIndex = 0; pageIndex < amountOfPages; pageIndex++)
    {
        var subscribers = subscriberRepository.GetByPage(pageSize, pageIndex);

        var message = new SendGridMessage
        {
            From = new EmailAddress(sender.Email, sender.Name),
            Subject = "Ahoy -FirstName_Raw-!",
            HtmlContent = "Welcome aboard <b>-FullName-</b> ⚓️"️,

            // max 1000 Personalizations
            Personalizations = subscribers.Select(s => new Personalization
            {
                Tos = new List<EmailAddress> {new EmailAddress(s.Email, s.FullName)},
                // Substitutions data is max 10,000 bytes per Personalization object
                Substitutions = new Dictionary<string, string>
                {
                    {"-FirstName_Raw-", s.FirstName},
                    {"-FullName-", htmlEncoder.Encode(s.FullName)}
                }
            }).ToList(),

            // max 72 hours from now
            SendAt = sendAt
        };

        var response = await sendGridClient.SendEmailAsync(message);
        if (response.IsSuccessStatusCode) logger.LogInformation("Email queued");
        else logger.LogError("Email not queued");
    }
}
Enter fullscreen mode Exit fullscreen mode

You can schedule emails to be sent up to 72 hours from now. If you need to schedule beyond that time, I recommend scheduling emails using a job scheduler like Quartz.NET or Hangfire.

Unsubscribe recipients

When recipients mark your emails as spam, it hurts your reputation. As an email sender, you want to maintain a good reputation to maximize your email deliverability. You must also comply with anti-spam laws. Make sure to use a double opt-in process for things like newsletters, and always provide an unsubscribe link in your emails. You can add your own unsubscribe logic, or you can use Global Unsubscribes or Group Unsubscribes from SendGrid.

MailHelper

The SendGrid SDK contains a helper class called MailHelper. MailHelper has convenient static methods for the most common email scenarios, including bulk email.

The CreateSingleEmailToMultipleRecipients method lets you conveniently create a SendGridMessage to send emails to many recipients. By default it will create a Personalization for every recipient, but there's an overload where you can set the showAllRecipients parameter to true. When this parameter is set to true, the method will create one Personalization with all the recipients in it.

The ​​CreateSingleTemplateEmailToMultipleRecipients method will create a SendGridMessage configured with a Dynamic Email Template and a Personalization for every recipient.

The CreateMultipleEmailsToMultipleRecipients method will create a SendGridMessage configured with a Personalization for every recipient and Substitution Tokens.

Which bulk email technique should you use?

You just learned many different ways to send email in bulk, but which should you use?

Not to be cliche, but it depends… Let's compare each technique:

When you're using loops, you have full control over every email you send and there are no limitations.However, this is the least performant solution because you have to send an HTTP request for each email.

Use loops when you need to do advanced customization of the subject and email body that is not possible using Substitution Tags, or with the Handlebars templating language. This solution is ideal if you want to generate the email body using the Razor templating language.

Using Personalizations will greatly improve the performance of your application because you can send 1,000 emails per HTTP request. If you don't need to customize the email body for each recipient, you can set the content once for all emails, without the need for Substitution Tokens or Dynamic Email Templates.

Personalizations with Substitution Tokens works great for doing simple email customizations where you need to substitute predefined variables for every recipient. You cannot do more complex templating like looping and conditional rendering.

When you're doing Personalizations with Dynamic Email Templates, you have to manage the email templates in SendGrid which could be a benefit or a drawback depending on your needs. Dynamic Email Templates use a fully featured templating language, Handlebars, which you can use to render the simplest to the most advanced templates.

If you don't want to store your contacts in your own database, you can store contacts in SendGrid and send emails to them using Single Sends. Single Sends uses the same Handlebar templating language, but can only access the data that is stored on the contact. This means you have to upload the data to your SendGrid contacts before sending the email that requires it. You can send emails to all your contacts, specific contact lists, or segments of your contacts and you can generate unsubscribe links. The unsubscribe links allow the contact to remove themselves from the contacts list. You also don't have to worry about looping or paginating over your contacts as SendGrid will do this for you. This is ideal for marketing emails and newsletters.

If you're still not sure, you could go down this decision tree:

How to bulk email programmatically? Store Contacts in SendGrid? If yes, use Single Sends. If no, Customize Email per Recipient? If no, Use Personalizations with static body. If yes, do you need advanced email customization? If no, use Personalizations with Substitution Tags or Dynamic Email Templates. If yes, do you want to store templates in SendGrid? If yes, use Personalizations with Dynamic Email Templates. If no, send emails in a loop.

Next steps

Big congratulations if you made it this far! 🎉

You learned how to send emails in bulk using a bunch of methods, each with their own advantages. However, this tutorial only covers the code side of things, but you should check out this guide on bulk emails to maximize the deliverability, engagement, and minimize the unsubscription rates.

If you want to see a practical example of bulk email in action, check out this tutorial on how to build an Email Newsletter application using ASP.NET Core and SendGrid. This tutorial manages the newsletter subscribers using Entity Framework Core and uses a form to upload the HTML newsletter which is then sent using Personalizations and Substitution Tokens.

Top comments (0)

Some comments have been hidden by the post's author - find out more