Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvement proposal for OrchardCoreContrib.Email.SendGrid: add support for SendGrid Dynamic Templates via SendGrid Client Library #135

Open
MarGraz opened this issue Jun 27, 2024 · 12 comments

Comments

@MarGraz
Copy link

MarGraz commented Jun 27, 2024

Hi,

I think it would be great if the OrchardCoreContrib.Email.SendGrid module could implement the SendGrid client library to send emails using pre-created Dynamic Templates.

SendGrid allows you to create email templates with placeholders in their backoffice, using a WYSIWYG editor (official guide here). You can then use the client library to select the template and pass dynamic text to the APIs. Here is an example of using Dynamic Templates.

Below is the code snippet from this example:

using SendGrid;
using SendGrid.Helpers.Mail;

var apiKey = Environment.GetEnvironmentVariable("SENDGRID_API_KEY");
var client = new SendGridClient(apiKey);
var from = new EmailAddress("{ Your verified email address }", "{ Sender display name }");
var to = new EmailAddress("{ Recipient email address }", "{ Recipient display name }");

var templateId = "{ Your dynamic template id }";
var dynamicTemplateData = new
{
    subject = $"To-Do List for {DateTime.UtcNow:MMMM}",
    recipientName = "Demo User", 
    todoItemList = new[]
    {
        new { title = "Organize invoices", dueDate = "11 June 2022", status = "Completed" },
        new { title = "Prepare taxes", dueDate = "12 June 2022", status = "In progress" },
        new { title = "Submit taxes", dueDate = "25 June 2022", status = "Pending" },
    }
};
var msg = MailHelper.CreateSingleTemplateEmail(from, to, templateId, dynamicTemplateData);

var response = await client.SendEmailAsync(msg);
if (response.IsSuccessStatusCode)
{
    Console.WriteLine("Email has been sent successfully");
}

I think that should be sufficient to add another method in the SendGridService.cs class, that allows the use of a Dynamic Template.

What do you think about it? 😊

Thank you

@MarGraz MarGraz changed the title Improvement proposal for OrchardCoreContrib.Email.SendGrid: add support for SendGrid Dynamic Templates via SendGrid .NET Library Improvement proposal for OrchardCoreContrib.Email.SendGrid: add support for SendGrid Dynamic Templates via SendGrid Client Library Jun 27, 2024
@hishamco
Copy link
Member

This could be a useful feature for email in general, do you want to send a PR for it? The template could be customizable: Liquid, Text .. etc

@MarGraz
Copy link
Author

MarGraz commented Jun 28, 2024

@hishamco I can implement the SendGrid one, adding a method in the SendGridService.cs class. But I need a new interface to use in DI, because ISmtpService doesn't provide any method to be used with the SendGrid template 🤔

Of course, in this case, the template is provided by SendGrid. But yes, I agree, having the possibility to create a template and manage a list of them could be a nice feature for the Email module in general.

Thank you

@hishamco
Copy link
Member

Let's start on https://github.com/OrchardCoreContrib/OrchardCoreContrib repo and add templating infrastructure to the OCC.Email

@hishamco
Copy link
Member

@MarGraz are you still interested to contribute on this one?

@MarGraz
Copy link
Author

MarGraz commented Sep 14, 2024

@hishamco Yes, I have created a method to use the SendGrid Dynamic Template, and I've built our own NuGet package based on the Contrib library. In our version I retrieve configuration parameters, such as the SendGrid Template Id, from the appsettings. I also pass a dynamic object to the template method to populate the {{placeholders}} (handlebars) within the SendGrid template.

I believe it's necessary to transfer these settings into the backend. Currently, I don't have the time to do this, but I might be able to fork your project and add the new template method.

Let me know. Thank you

@hishamco
Copy link
Member

I have been waiting for your contribution since last time :) anyhow feel free to submit a PR when you have time

@MarGraz
Copy link
Author

MarGraz commented Sep 14, 2024

@hishamco I think I will add the new method in the SmtpService class, could be ok? Or do you prefer a new Service class?

I think I can work on it during the week.

Thank you

@hishamco
Copy link
Member

While the templating is something that could be used across any email we could introduce another type that will be injected later. Let me propose something or you could share your proposal before you dive into the could to save the time

@hishamco
Copy link
Member

hishamco commented Sep 15, 2024

After thinking I suggest to add templating APIs with some providers: Text Template, Razor Template, HandleBars ... ect, then the email module could utilize those APIs

So, let me start bulding the infrastructure APIs then you can build on top of it. BTW I presume you are using templates for email body only, right?

@MarGraz
Copy link
Author

MarGraz commented Sep 16, 2024

Hi @hishamco,

Yes, right now I'm creating the template in SendGrid. I’m using some pre-made HTML templates adding them "handlebars". On the Orchard Core side, I have a dynamic template to share those "Personalizations".

Below is what I've done so far, with the code included. Note that this is just a demo, so I need to refactoring it 😅

I think a useful feature would be to create items for DynamicTemplateData directly from the OrchardCore backoffice, and also set the Template ID to use. It might be helpful to support multiple template IDs, for example I use different templates for each email type via the API. I’m unsure how to adapt the current module UI to accommodate this 🤔

Let me know how you usually approach refactoring and if you think a dedicated class is needed. I can then start working on it in a new branch. I will need some help with the backoffice part, as I'm fairly new to modifying the DisplayDriver and similar components in the BO.

Here the code.
I modified the SendGridService.cs (this one) as follows:

  /// <summary>
  /// Represents a service for sending emails using SendGrid emailing service.
  /// </summary>
  public class SendGridService : ISendGridService
  {
      private static readonly char[] EmailsSeparator = new char[] { ',', ';', ' ' };
      private static readonly Regex HtmlTagRegex = new Regex(@"<[^>]*>", RegexOptions.Compiled);

      private readonly SendGridSettings _sendGridSetting;
      private readonly IStringLocalizer S;

      /// <summary>
      /// Initializes a new instance of <see cref="SendGridService"/>.
      /// </summary>
      /// <param name="sendGridSetting">The <see cref="IOptions<GmailSettings>"/>.</param>
      /// <param name="stringLocalizer">The <see cref="IStringLocalizer<GmailService>"/>.</param>
      public SendGridService(
          IOptions<SendGridSettings> sendGridSetting,
          IStringLocalizer<SendGridService> stringLocalizer
          )
      {
          _sendGridSetting = sendGridSetting.Value;
          S = stringLocalizer;
      }

      /// <inheritdoc/>
      public async Task<SmtpResult> SendAsync(MailMessage message)
      {
          if (_sendGridSetting?.DefaultSender == null)
          {
              return SmtpResult.Failed(S["SendGrid settings must be configured before an email can be sent."]);
          }

          try
          {
              message.From = string.IsNullOrWhiteSpace(message.From)
                  ? _sendGridSetting.DefaultSender
                  : message.From;

              var sendGridMessage = FromMailMessage(message);

              var client = new SendGridClient(_sendGridSetting.ApiKey);

              await client.SendEmailAsync(sendGridMessage);

              return SmtpResult.Success;
          }
          catch (Exception ex)
          {
              return SmtpResult.Failed(S["An error occurred while sending an email: '{0}'", ex.Message]);
          }
      }

 /// <summary>
 /// Send an Email using the SendGrid DynamicTemplateData.
 /// </summary>
 /// <param name="message">The MailMessage.</param>
 /// <param name="dynamicTemplateData">A custom model that will be passed inside the SendGrid "Personalizations" object, used to pass a value to the handlebars (placeholders) in SendGrid.</param>
 /// <returns></returns>
 public async Task<SmtpResult> SendUsingSendGridDynamicTemplateAsync(MailMessage message, DynamicTemplateData dynamicTemplateData)
 {
     if (_sendGridSetting?.DefaultSender == null)
     {
         return SmtpResult.Failed(S["SendGrid settings must be configured before an email can be sent."]);
     }

     if (dynamicTemplateData == null || dynamicTemplateData.SendGridDynamicTemplateId == null)
     {
         return SmtpResult.Failed(S["Dynamic template ID must be provided for SendGrid dynamic template emails."]);
     }

     try
     {
         // Create the SendGrid client
         var client = new SendGridClient(_sendGridSetting.ApiKey);

         // Prepare the message for SendGrid
         var sendGridMessage = FromMailMessage(message, dynamicTemplateData);

         // Send the email
         var response = await client.SendEmailAsync(sendGridMessage);

         if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Accepted)
         {
             return SmtpResult.Success;
         }

         var responseBody = await response.Body.ReadAsStringAsync();
         return SmtpResult.Failed(S[$"An error occurred while sending an email: '{responseBody}'"]);
     }
     catch (Exception ex)
     {
         return SmtpResult.Failed(S[$"An error occurred while sending an email: '{ex.Message}'"]);
     }
 }

 /// <summary>
 /// Used for a normal email, that is not using the SendGrid Dynamic Template.
 /// </summary>
 /// <param name="message">The MailMessage.</param>
 /// <returns></returns>
 private SendGridMessage FromMailMessage(MailMessage message)
 {
     var senderAddress = string.IsNullOrWhiteSpace(message.Sender)
         ? _sendGridSetting.DefaultSender
         : message.From;

     var sendGridMessage = new SendGridMessage
     {
         From = new EmailAddress(senderAddress)
     };

     if (!string.IsNullOrWhiteSpace(message.To))
     {
         foreach (var address in message.To.Split(EmailsSeparator, StringSplitOptions.RemoveEmptyEntries))
         {
             sendGridMessage.AddTo(new EmailAddress(address));
         }
     }

     // If no template, just send a plain text or HTML email
     if (message.IsHtmlBody)
     {
         sendGridMessage.HtmlContent = message.Body;
         sendGridMessage.PlainTextContent = HtmlTagRegex.Replace(message.Body, string.Empty);
     }
     else
     {
         sendGridMessage.PlainTextContent = message.Body;
     }

     return sendGridMessage;
 }


 /// <summary>       
 /// Used for the email is using a SendGrid Dynamic Template
 /// </summary>
 /// <param name="message">The MailMessage.</param>
 /// <param name="dynamicTemplateData">A custom model that will be passed inside the SendGrid "Personalizations" object, used to pass a value to the handlebars (placeholders) in SendGrid.</param>
 /// <returns></returns>
 private SendGridMessage FromMailMessage(MailMessage message, DynamicTemplateData dynamicTemplateData)
 {
     var senderAddress = string.IsNullOrWhiteSpace(message.Sender)
         ? _sendGridSetting.DefaultSender
         : message.From;

     var sendGridMessage = new SendGridMessage();

     sendGridMessage.SetFrom(new EmailAddress(senderAddress));
     //sendGridMessage.SetGlobalSubject(message.Subject); // Those method are not working, so I passed the subject into the DynamicTemplateData, it was the only working solution
     //sendGridMessage.SetSubject(message.Subject);

     if (!string.IsNullOrWhiteSpace(message.To))
     {
         foreach (var address in message.To.Split(EmailsSeparator, StringSplitOptions.RemoveEmptyEntries))
         {
             sendGridMessage.AddTo(new EmailAddress(address));
         }
     }

     // Handle dynamic template data
     if (!string.IsNullOrWhiteSpace(dynamicTemplateData.SendGridDynamicTemplateId))
     {
         // Set the template ID
         sendGridMessage.TemplateId = dynamicTemplateData.SendGridDynamicTemplateId;

         // Prepare a dictionary to hold template data dynamically
         var templateData = new Dictionary<string, object>();

         // Add EmailParts to the template data dynamically
         if (dynamicTemplateData.EmailParts != null)
         {
             foreach (var prop in dynamicTemplateData.EmailParts.GetType().GetProperties())
             {
                 templateData.Add(prop.Name, prop.GetValue(dynamicTemplateData.EmailParts));
             }
         }

         // Add Placeholders to the template data dynamically
         if (dynamicTemplateData.Placeholders != null)
         {
             foreach (var placeholder in dynamicTemplateData.Placeholders)
             {
                 templateData.Add(placeholder.Key, placeholder.Value);
             }
         }
         // Set the template data in the SendGrid message
         sendGridMessage.SetTemplateData(templateData);
     }
     else
     {
         // If no template, just send a plain text or HTML email
         if (message.IsHtmlBody)
         {
             sendGridMessage.HtmlContent = message.Body;
             sendGridMessage.PlainTextContent = HtmlTagRegex.Replace(message.Body, string.Empty);
         }
         else
         {
               if (result.Errors != null && result.Errors.Any())
               {
                    Console.WriteLine("Failed to send email:");
                    foreach (var error in result.Errors)
                    {
                        Console.WriteLine($"- {error}");
                    }
               }
          }     
       }

     return sendGridMessage;
    }
  }

This is my custom DynamicTemplateData class, used to pass values to the "handlebars" on SendGrid:

public class DynamicTemplateData
{
    public string SendGridDynamicTemplateId { get; set; }
    public EmailParts EmailParts { get; set; }
    public List<EmailPlaceholder> Placeholders { get; set; }
}

public class EmailParts
{
    public string subject { get; set; } // ATTENTION: do not change it in uppercase, otherwise it will not work with the placeholder {{subject}} in the SendGrid template. Is not possible to use [JsonProperty] or [JsonPropertyName], they are not working because it seems that SendGrid uses their own parser.
    public string RecipientName { get; set; }
}

public class EmailPlaceholder
{
    public string Key { get; set; }
    public object Value { get; set; }
}

To test it quickly, I created a Console App, inside your module which I downloaded locally.
First of all you need a SendGrid account (create an API key and verify your email address) and a Dynamic Template (to get the Template ID). You can replace placeholders in DynamicTemplateData as needed. For more details, see the SendGrid Dynamic Templates documentation.

class Program
{
    private static void ConfigureServices(ServiceCollection services)
    {
        // Add logging services
        services.AddLogging(configure => configure.AddConsole());

        // Add localization services
        services.AddLocalization();

        // Add SendGridService to DI with necessary configuration
        services.AddTransient<ISendGridService, SendGridService>();

        // Mocking SendGridSettings with necessary API key and default sender
        services.Configure<SendGridSettings>(options =>
        {
            options.ApiKey = "*******************";  // ----- Replace with your actual SendGrid API key
            options.DefaultSender = "youremailsender@gmail.com";  // ----- Replace with your sender email
        });
    }

    static async Task Main(string[] args)
    {
        Console.WriteLine("Testing SendGrid Email Sending...");

        // Set up dependency injection (if needed)
        ServiceCollection services = new ServiceCollection();
        ConfigureServices(services);

        ServiceProvider serviceProvider = services.BuildServiceProvider();

        // Get the SendGrid service from DI
        ISendGridService sendGridService = serviceProvider.GetRequiredService<ISendGridService>();

        // Prepare a test email message
        MailMessage message = new MailMessage
        {
            To = "recipient-email@gmail.com", // ----- Replace with your recipient email
            IsHtmlBody = true
        };

       // Those are the placeholders I have inside my SendGrid template, 
       // I can use it writing, for example: {{emailbody.AcceptStoreUri}}
        Dictionary<string, string> emailBodyItems = new Dictionary<string, string>
        {
            { "UsernameStore", "Store Pippo" },
            { "EmailStore", "OurStore@email.com" },
            { "AcceptStoreUri", "https://accept.uri" },
            { "RefuseStoreUri", "https://refuse.uri" }
        };

        DynamicTemplateData dynamicTemplateData = new DynamicTemplateData
        {
            SendGridDynamicTemplateId = "d-*******************************ab3",  // ----- Replace with your SendGrid Template Id
            EmailParts = new EmailParts
            {
                subject = $"This is a test email",
                RecipientName = "Admin"
            },
            Placeholders = new List<EmailPlaceholder>
            {
                new EmailPlaceholder { Key = "emailbody", Value = emailBodyItems }
            }
        };

        // Send email using dynamic template
        SmtpResult result = await sendGridService.SendUsingSendGridDynamicTemplateAsync(message, dynamicTemplateData);

        if (result.Succeeded)
        {
            Console.WriteLine("Email sent successfully!");
        }
        else
        {
            Console.WriteLine($"Failed to send email: {result.Errors}");
        }
    }
}

Thank you

@hishamco
Copy link
Member

Thanks, @MarGraz, I think we need to start building templating infrastructure APIs, and then we could add a feature Email Templates that allows you to create multiple templates as you mentioned before

Let me start after Orchard Core 2.0.0 update, and I will let you know once it's there

@MarGraz
Copy link
Author

MarGraz commented Sep 17, 2024

@hishamco no problem. When you start a new branch, let me know how I can help 😊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants