Our Requirements
Imagine we need to configure Kentico for testing and we have real customer or user data in the database.
We need to allow for testers and stakeholders to fully test the system - this means when they perform operations that would send emails, those emails should be sent ππΎ!
However, we can't allow emails to be sent out to those real users from our testing environment π¨.
Telling everyone "Don't click this button because that could send out an email to a non-test user" isn't a great solution π.
We need a way to whitelist a set of addresses or domains to receive emails, and intercept attempts to send emails to all others π€.
First, we need to identify all features in Kentico EMS that could result in emails being sent...
What Features in Kentico EMS Send Emails?
Kentico uses its email system to support a lot of functionality within the application. This includes:
- Email marketing
- Page translation requests
- E-Commerce notifications
- Authentication
- Publishing workflows
- Form submissions
... and more ...
That's a lot of different email configuration to manage!
So, it's unlikely that we'll be able to customize all these parts of the application to whitelist emails π.
Maybe there's a single, central service that Kentico uses to send emails where we could make our customizations?
CMS.EmailEngine.EmailProvider
Fortunately for us, Kentico exposes a Provider class that does exactly what we need!
Looking through the Kentico EMS documentation we can find information about the CMS.EmailEngine.EmailProvider
class πͺπΎ.
The documentation states:
Customizing the email provider allows you to:
- Execute custom actions when sending emails (for example logging the sent emails for auditing purposes)
- Use thirdβparty components for sending emails
Once you create and register your custom email provider, it is used to process all emails sent out by Kentico.
Perfect!
The EmailProvider
class exposes (3) protected methods we can override, however we only need to focus on (2) of them:
/// <summary>
/// Synchronously sends an email through the SMTP server.
/// </summary>
protected override void SendEmailInternal(
string siteName, MailMessage message, SMTPServerInfo smtpServer)
/// <summary>
/// Asynchronously sends an email through the SMTP server.
/// </summary>
protected override void SendEmailAsyncInternal(
string siteName, MailMessage message,
SMTPServerInfo smtpServer, EmailToken emailToken)
Creating Our WhitelistEmailProvider
To override and intercept Kentico's email processing, we need to create our sub-class and register it as a custom provider:
// β
Don't forget this attribute
[assembly: RegisterCustomProvider(typeof(WhitelistEmailProvider))]
namespace Sandbox.Infrastructure.Emails
{
public class WhitelistEmailProvider: EmailProvider
{
protected override void SendEmailAsyncInternal(
string siteName, MailMessage message,
SMTPServerInfo smtpServer, EmailToken emailToken)
{
// ... insert custom functionality
base.SendEmailAsyncInternal(
siteName, message, smtpServer, emailToken);
}
protected override void SendEmailInternal(
string siteName, MailMessage message, SMTPServerInfo smtpServer)
{
... insert custom functionality
base.SendEmailInternal(siteName, message, smtpServer);
}
}
}
We still call the parent class methods (base.SendEmailInternal()
) because we don't want to write all the email processing logic - just a small part.
Configuring an Email Whitelist
Now that we have a spot in our code base to whitelist and intercept outgoing emails, we need to decide how we are actually going to do that whitelisting.
Let's use Kentico's support for Custom Settings to define a place where site administrators can update the email whitelist settings π.
Take a look at Kentico's documentation on Creating custom modules for more information on what Custom Modules are and how they work.
In the screenshot below you can see I've created a Custom Module named Sandbox
and I'm about to modify the Settings -> System -> Emails
node from within this module:
I enter some values for a "New settings group":
And then I create several "New settings key" entries:
Setting 1:
- Display Name: Is Whitelisting Enabled
- Code name:
SANDBOX_EMAIL_WHITELIST_IS_WHITELIST_ENABLED
- Description: Check to enable email whitelisting
- Type: Boolean
- Editing control: Default
Setting 2:
- Display Name: Whitelisted Emails
- Code name:
SANDBOX_EMAIL_WHITELIST_WHITELISTED_EMAILS
- Description: Domain suffixes or specific email addresses, semi-colon delimited that will be whitelisted from email interception
- Type: Text
- Editing control: Form Control -> Text area
Setting 3:
- Display Name: Intercepting Email Address
- Code name:
SANDBOX_EMAIL_WHITELIST_INTERCEPTING_ADDRESS
- Description: When email whitelisting is enabled, any non-whitelisted emails will be send to this address instead of the intended recipients
- Type: Text
- Editing control: Default
Our resulting UI for these custom settings should look like the following screenshot:
Creating EmailWhitelistSettings
Now that we have a place to store our settings for email whitelisting and a custom class to override email sending functionality, we can add our custom code.
First, let's create a EmailWhitelistSettings
class to abstract out our custom settings.
We create a nested class to hold our settings keys. By using the nameof()
operator, I get the string value of the key to match the key name automatically π€:
public static class EmailWhitelistSettings
{
public static class SettingKeys
{
public const string SANDBOX_EMAIL_WHITELIST_IS_WHITELIST_ENABLED =
nameof(SANDBOX_EMAIL_WHITELIST_IS_WHITELIST_ENABLED);
public const string SANDBOX_EMAIL_WHITELIST_WHITELISTED_EMAILS =
nameof(SANDBOX_EMAIL_WHITELIST_WHITELISTED_EMAILS);
public const string SANDBOX_EMAIL_WHITELIST_INTERCEPTING_ADDRESS =
nameof(SANDBOX_EMAIL_WHITELIST_INTERCEPTING_ADDRESS);
}
We make (2) private fields to hold some default values:
private static readonly string fallbackWhitelist =
"<YOUR FALLBACK WHITELIST>";
private static readonly string fallbackInterceptingAddress =
"<YOUR FALLBACK INTERCEPTING ADDRESS>";
Now we make several static
methods to retrieve the values from settings:
public static IEnumerable<string> WhitelistedRecipients()
{
string whitelistedRecipients = SettingsKeyInfoProvider.GetValue(
SettingKeys.SANDBOX_EMAIL_WHITELIST_WHITELISTED_EMAILS);
if (IsDebug())
{
whitelistedRecipients = string.IsNullOrWhiteSpace(whitelistedRecipients)
? fallbackWhitelist
: whitelistedRecipients;
}
return (whitelistedRecipients ?? "")
.Split(';')
.Select(r => r.Trim());
}
public static string InterceptingAddress()
{
string interceptingAddress = SettingsKeyInfoProvider.GetValue(
SettingKeys.ZEL01_EMAIL_WHITELIST_INTERCEPTING_ADDRESS);
if (IsDebug())
{
interceptingAddress = string.IsNullOrWhiteSpace(interceptingAddress)
? fallbackInterceptingAddress
: interceptingAddress;
}
return interceptingAddress;
}
public static bool IsWhitelistEnabled =>
IsDebug()
? true
: SettingsKeyInfoProvider.GetBoolValue(
SettingKeys.ZEL01_IS_EMAIL_WHITELIST_ENABLED);
What about that IsDebug()
method?
private static bool IsDebug()
{
bool isDebug = false;
#if DEBUG
isDebug = true;
#endif
return isDebug;
}
We can see here, that I use a pre-processor directive to ensure that if I'm doing a DEBUG
build (running the site locally), I'm guaranteed to have whitelisting (and fallbacks) enabled.
This prevents me from accidentally running some code locally that sends out emails to real users π!
We could also use a local email server for this (like hMailServer, MailSlurper, or Papercut but a code level check is double-safe!
Finally, we add a utility method to determine if a given email address has been whitelisted:
public bool IsWhitelistedRecipient(string recipient) =>
whitelistedEmails.Any(e => e.StartsWith("@")
? recipient.EndsWith(e, StringComparison.OrdinalIgnoreCase)
: string.Equals(recipient, e, StringComparison.OrdinalIgnoreCase));
This method will match on full address or a domain suffix. For example, if the following are whitelisted, "test@test.com;@wiredviews.com", all of my co-workers could receive emails normally, along with "test@test.com". All other addresses are going to be captured and re-routed π§.
Customizing the WhitelistEmailProvider
Ok! We're finally ready to custom the WhitelistEmailProvider
.
I'm only going to show the
SendEmailInternal
method, since the other will be implemented the same way.
The override is pretty simple because all the functionality is going to be in a ProcessEmail
method:
private override void SendEmailInternal(
string siteName, MailMessage message, SMTPServerInfo smtpServer)
{
base.SendEmailInternal(siteName, ProcessEmail(message), smtpServer);
}
We can see in ProcessEmail
that determines if the email should be handled or directly returned:
protected MailMessage ProcessEmail(MailMessage message) =>
EmailWhitelistSettings.IsWhitelistEnabled && !IsWhitelisted(message)
? ModifyMessage(message)
: message;
We use the IsWhitelisted(MailMessage message)
method to determine if the given message has any non-whitelisted email addresses in all of its sending fields (To, CC, Bcc):
private bool IsWhitelisted(MailMessage message)
{
var whitelisted = EmailWhitelistSettings.WhitelistedRecipients();
return message.To.All(a => EmailWhitelistSettings
.IsWhitelistedRecipient(a.Address, whitelisted))
&& message.CC.All(a => EmailWhitelistSettings
.IsWhitelistedRecipient(a.Address, whitelisted))
&& message.Bcc.All(a => EmailWhitelistSettings
.IsWhitelistedRecipient(a.Address, whitelisted));
}
Finally we have a larger method, ModifyMessage(MailMessage message)
, which is called when the recipients of the email are not whitelisted.
In this method we want to change the recipients to use the intercepting address, and record the original recipients at the bottom of the email's body:
/// <summary>
/// Replaces the recipients fields with intercepting email address and appends
/// the original recipients to the end of the email
/// </summary>
/// <param name="message"></param>
/// <returns></returns>
private MailMessage ModifyMessage(MailMessage message)
{
// β
Grab all the original recipients
var originalTo = message.To.Select(e => e.Address).ToList();
var originalCc = message.CC.Select(e => e.Address).ToList();
var originalBcc = message.Bcc.Select(e => e.Address).ToList();
// β
Now clear them out
message.To.Clear();
message.CC.Clear();
message.Bcc.Clear();
// β
Add our intercepting address as the only recipient
message.To.Add(
new MailAddress(EmailWhitelistSettings.InterceptingAddress()));
// β
Indicate in the subject this email has been overriden
message.Subject = $"{message.Subject}: override";
// β
Find the HTML email (ignore plain text for now)
var nullableView = message
.AlternateViews
.FirstOrDefault(m =>
m.ContentType.MediaType == MediaTypeNames.Text.Html);
if (!(nullableView is AlternateView view))
{
return message;
}
// β
Remove the existing HTML content from the email
message.AlternateViews.Remove(view);
var builder = new StringBuilder();
string template = "<p>{0}</p>";
// β
Read out the HTML content as a string
using (var reader = new StreamReader(view.ContentStream))
{
string originalContents = reader.ReadToEnd();
builder.Append(originalContents);
}
// β
Create all the override information
builder.AppendFormat(template, $"--- Email Override ---");
builder.AppendFormat(template,
$"original to: {string.Join(", ", originalTo.ToList())}");
if (originalCc.Any())
{
builder.AppendFormat(template,
$"original cc: {string.Join(", ", originalCc.ToList())}");
}
if (originalBcc.Any())
{
builder.AppendFormat(template,
$"original bcc: {string.Join(", ", originalBcc.ToList())}");
}
string updatedBody = builder.ToString();
var originalContentType = new ContentType(MediaTypeNames.Text.Html));
// β
Create new HTML content and attach it to the email
string newView = AlternateView
.CreateAlternateViewFromString(updatedBody, originalContentType);
message.AlternateViews.Add(newView);
return message;
}
The AlternateView
that makes up the content of the MailMessage
is not editable π, so we instead have to remove it from the MailMessage
, read its content as a string and append our override messages to it.
We then create a new AlternateView
that has our updated content as the email body, and add it to the MailMessage
.
There's definitely some extra processing going on here that you don't want to run during production while sending out bulk emails. However, that's definitely not a scenario for this email whitelisting π.
Assuming our settings are correctly configured in the CMS and the custom provider is registered with Kentico (by using the assembly attribute), when our application attempts to send emails, they will be checked by the whitelisting process and either re-routed or sent out correctly π!
Conclusion
As we develop our Kentico applications we might find the need to test parts of the system that will send out emails using Kentico's underlying email processing.
If we have any real email addresses in the database, we run the risk of emails being sent to users from non-production sites π¦.
We could use SMTP servers that capture emails, but that might not allow testers and stakeholders to verify the functionality of the site as it would be in a production scenario. It also adds another tool that testers would need to have access to while testing common workflows π.
Instead, we can leverage Kentico's robust overriding/intercepting patterns of its internal providers π .
By designing an email "whitelist" feature, we can enable email whitelisting for testers and stakeholders while still intercepting and re-routing emails destined for real, non-test addresses.
Kentico's custom settings functionality allows site administrators easy access to these settings within the CMS, which is great for enabling new domains or email addresses as the testing phase of a site proceeds ππΎ.
Finally, once the site is ready to go live, we can either disable whitelisting in the CMS settings, remove the custom provider attribute to un-register it in Kentico or delete the class entirely, at which point email processing by the CMS will return to normal.
Pretty cool π.
As always, thanks for reading π!
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:
Or my Kentico blog series:
Top comments (0)