Issues

Use Umbraco Forms Workflows to Enhance Your Forms Functionality

In this article, I'll explore how to extend Umbraco Forms by creating custom workflows. I'll build a custom workflow that detects and redirects form submissions containing nonsense or gibberish using a specialized classifier, and then apply a second workflow to check for disposable email addresses. Extending workflows can add significant value to Umbraco Forms by integrating custom business logic, enhancing validation, and allowing more tailored responses to form submissions. In my case, I had a very vexing problem that OOTB solutions could not adequately address.

Our use case is to prevent both bot and malicious human submitted forms from reaching our other systems (handled by other workflows not shown here). In addition, we detected a high-number of fraudulent clicks and form submissions originating from our pay-per-click assets that resulted in both a higher cost and inaccurate conversion data. We wanted to prevent these actors from reaching the pages that record conversions. All of our forms use the built-in Forms reCaptcha3 property but, unfortunately, that proved insufficient in preventing these malicious form submissions.

I've included the complete set of classes needed to implement, update, and extend this approach so there are a fair number of lines of code. I did this intentionally as I find it counterproductive when I reference articles myself that do not contain complete code. Let me know how that works for you. Also note, this same pattern to create and register custom Umbraco Forms Workflows can be found in the official Umbraco documentation.

What Are Umbraco Forms Workflows?

Umbraco Forms workflows allow you to define what should happen after a form is Submitted, Approved, or Rejected. This can include sending emails, saving data to external systems, or performing custom actions—like validating submissions based on specific criteria. By creating custom workflows, you can adapt Umbraco Forms to fit the unique requirements of your project. Here I use a combination of custom workflows (read on!) and built-in Umbraco Forms Workflows.

In this article, we'll build two custom workflows:

  1. GibberishWorkflow: Designed to identify and handle nonsensical or gibberish messages in form submissions.
  2. DisposableEmailWorkflow: Designed to check if the email address used in the submission is a disposable email and to handle flagged top-level domains.

If the form is rejected by the first workflow (GibberishWorkflow), the second workflow will not execute and the form submission is marked as Rejected. This allows us to run a workflow in the On Reject stage where we use the built-in Forms work flow to post to a Slack channel. That allows our team to quickly scan rejected submissions in that case a submission was falsely identified as gibberish or containing a disposable email.

Setting Up

For the following code I used Umbraco CMS and Umbraco Forms verison 13 running on Umbraco Cloud but the code will be identical for versions 10-15 (the most recent at the time of this writing). I use Visual Studio 2022 and .NET 8 for my Umbraco custom work but any .NET IDE or code editor will work with the code below.

The sample workflows below will classify a message as gibberish and check for disposable emails using injected services (IGibberishClassifierService and IDisposableEmailService). With that background, let's walk through the implementation step-by-step.

Creating the Gibberish Classifier Workflow

Below is the complete example of the GibberishWorkflow class, which will be registered as a custom workflow type.

Forms.Gibberish.Workflow.cs

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Skrift.Io.Services;
using Umbraco.Forms.Core;
using Umbraco.Forms.Core.Enums;
using Umbraco.Forms.Core.Services;

namespace Skrift.Io.Workflows
{
    public class GibberishWorkflow : WorkflowType
    {
        private readonly ILogger _logger;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly IRecordService _recordService;
        private readonly IGibberishClassifierService _gibberishClassifierService;

        public GibberishWorkflow(ILogger logger,
                                    IHttpContextAccessor httpContextAccessor,
                                    IRecordService recordService,
                                    IGibberishClassifierService gibberishClassifierService)
        {
            _logger = logger;
            _httpContextAccessor = httpContextAccessor;
            _recordService = recordService;
            _gibberishClassifierService = gibberishClassifierService;
            this.Name = "Gibberish Classifier Workflow";
            this.Id = new Guid("BB0F22A1-B15F-4C64-8F32-90B4E0E52CB6");
            this.Description = "Gibberish Classifier Workflow";
            this.Icon = "icon-glasses";
        }

        public override async Task ExecuteAsync(WorkflowExecutionContext context)
        {
            // Assume this is a valid inquiry, set the redirect to a 'THANK YOU' page
            _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/thank-you";

            string message = context.Record.ValueAsString("message");

            // if message is empty, return early
            if (string.IsNullOrEmpty(message))
            {
                _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/no-thank-you";
                _logger.LogInformation($"Inquiry from {context.Record.ValueAsString("email")} has no message and was Rejected.");
                _recordService.RejectAsync(context.Record, context.Form).Wait();
                return await Task.FromResult(WorkflowExecutionStatus.Completed);
            }

            // check if the message is gibberish and redirect to a 'NO thank you' page if matched
            if (_gibberishClassifierService.IsGibberish(message))
            {
                _logger.LogInformation($"Inquiry from {context.Record.ValueAsString("email")} has only gibberish and was Rejected. Message: {message}");
                _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/no-thank-you";
                _recordService.RejectAsync(context.Record, context.Form).Wait();
            }

            return await Task.FromResult(WorkflowExecutionStatus.Completed);
        }

        public override List ValidateSettings()
        {
            return new List();
        }
    }
}

Creating the Disposable Email Checker Workflow

The second workflow, DisposableEmailWorkflow, will check if the submitted email is from a disposable email provider or contains a flagged top-level domain. If the email is flagged, the workflow will reject the submission and redirect to a 'no thank you' page. We set the flagged top level domains in appsettings.json to include the below. Certainly you can adjust to fit what is desirable or not in your region.

"FlaggedTopLevelDomains": "ru,cn,in,br,pl,id,pk,vn,ro,ua,ph,cz,hu,bg,eg,th,gr"

Forms.DisposableEmail.Workflow.cs

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Skrift.Io.Services;
using Umbraco.Forms.Core;
using Umbraco.Forms.Core.Enums;
using Umbraco.Forms.Core.Services;

namespace Skrift.Io.Workflows
{
    public class DisposableEmailWorkflow : WorkflowType
    {
        private readonly ILogger _logger;
        private readonly IConfiguration _configuration;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly IRecordService _recordService;
        private readonly IDisposableEmailService _disposableEmailService;

        public DisposableEmailWorkflow(ILogger logger, 
                                    IConfiguration configuration, 
                                    IHttpContextAccessor httpContextAccessor, 
                                    IRecordService recordService,
                                    IDisposableEmailService disposableEmailService)
        {
            _logger = logger;
            _configuration = configuration;
            _httpContextAccessor = httpContextAccessor;
            _recordService = recordService;
            _disposableEmailService = disposableEmailService;
            this.Name = "Disposable Email Checker Workflow";
            this.Id = new Guid("7BFC2D06-1660-4219-BDBC-028EE1A5A918");
            this.Description = "Disposable Email Checker Workflow";
            this.Icon = "icon-alert-alt";
        }

        public override async Task ExecuteAsync(WorkflowExecutionContext context)
        {
            // if the record is already rejected, return early
            if (context.Record.State == FormState.Rejected)
            {
                return await Task.FromResult(WorkflowExecutionStatus.Completed);
            }

            // Assume this is a valid email, set the redirect to a 'THANK YOU' page
            _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/thank-you";

            string email = context.Record.ValueAsString("email");

            // if email is empty, return early
            if (string.IsNullOrEmpty(email))
            {
                _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/no-thank-you";
                _recordService.RejectAsync(context.Record, context.Form).Wait();
                return await Task.FromResult(WorkflowExecutionStatus.Completed);
            }

            // check if the email is a disposable email address and redirect to a 'NO thank you' page if matched
            if (_disposableEmailService.IsDisposableEmail(email))
            {
                _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/no-thank-you";
                _recordService.RejectAsync(context.Record, context.Form).Wait();
                return await Task.FromResult(WorkflowExecutionStatus.Completed);
            }

            // check the top-level domain of the email address and redirect to a 'NO thank you' page if matched
            var flaggedTopLevelDomains = (string)_configuration.GetValue(typeof(string), "FlaggedTopLevelDomains");
            if (flaggedTopLevelDomains.Contains(email.Substring(email.LastIndexOf('.') + 1)))
            {
                _httpContextAccessor.HttpContext.Items[Constants.ItemKeys.RedirectAfterFormSubmitUrl] = "/no-thank-you";
                _recordService.RejectAsync(context.Record, context.Form).Wait();
                return await Task.FromResult(WorkflowExecutionStatus.Completed);
            }
            else
            {
                // if we made it this far, the email is valid, Approve the record
                _recordService.ApproveAsync(context.Record, context.Form).Wait();
            }

            return await Task.FromResult(WorkflowExecutionStatus.Completed);
        }

        public override List ValidateSettings()
        {
            return new List();
        }
    }
}

Walkthrough of the Workflow Implementations

GibberishWorkflow

The GibberishWorkflow is responsible for identifying gibberish messages. If the message is deemed gibberish, it redirects the user to a 'no thank you' page and rejects the form submission. For our purposes, "gibberish" is any word not contained in the 10,000 most common US English words. You can find many variations of these word lists at https://github.com/greenmoss/w and other repos.

DisposableEmailWorkflow

The DisposableEmailWorkflow is executed only if the submission is not rejected by the GibberishWorkflow. It checks whether the email is disposable or uses a flagged top-level domain. If any condition is met, it redirects to a 'no thank you' page and rejects the form submission.

This ensures that submissions are filtered in a sequential manner, with the second workflow (DisposableEmailWorkflow) only running if the first workflow (GibberishWorkflow) allows it.

Both of these workflows are executed at the form submission On Submit stage.

Services Used in Workflows

IDisposableEmailService

The IDisposableEmailService interface defines the contract for the DisposableEmailService.

IDisposableEmailService.cs

namespace Skrift.Io.Services
{
    public interface IDisposableEmailService
    {
        bool IsDisposableEmail(string email);
    }
}

IGibberishClassifierService

The IGibberishClassifierService interface defines the contract for the GibberishClassifierService.

IGibberishClassifierService.cs

namespace Skrift.Io.Services
{
    public interface IGibberishClassifierService
    {
        bool IsGibberish(string input);
    }
}

DisposableEmailService

The DisposableEmailService is used to determine whether an email address belongs to a disposable email provider. The service uses a list of disposable domains that is loaded during initialization.

DisposableEmailService.cs

using Microsoft.AspNetCore.Hosting;
using System.Net.Mail;

namespace Skrift.Io.Services
{
    public class DisposableEmailService : IDisposableEmailService
    {
        private HashSet<string> _disposableDomains;
        private readonly IWebHostEnvironment _environment;

        public DisposableEmailService(IWebHostEnvironment webHostEnvironment)
        {
            _environment = webHostEnvironment;
            _disposableDomains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            LoadDisposableDomains();
        }

        private void LoadDisposableDomains()
        {
            var filePath = Path.Combine(_environment.WebRootPath, "disposable_email_blocklist.conf");

            if (File.Exists(filePath))
            {
                var domains = File.ReadAllLines(filePath).Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith("//"));
                _disposableDomains = new HashSet<string>(domains, StringComparer.OrdinalIgnoreCase);
            }
        }

        public bool IsDisposableEmail(string email)
        {
            var host = new MailAddress(email).Host;
            var isDisposable = _disposableDomains.Contains(host);

            return isDisposable;
        }
    }
}

The LoadDisposableDomains() method reads a file (disposable_email_blocklist.conf) containing disposable domains, ignoring lines that are comments (starting with //) or empty. This pattern is useful for managing configuration data in an easy-to-update file, ensuring that the service remains flexible and maintainable. This is a simple text file with one entry per line as below.

0-mail.com
027168.com
0815.ru
0815.ry
0815.su
0845.ru
0box.eu
0clickemail.com
...

The disposable_email_blocklist.conf file is placed in the root of our Umbraco site (/wwwroot) and is loaded into memory when the site starts up because DisposableEmailService is registered in the dependency injection (DI) container. The same goes for the google-10000-english-usa.conf file referenced below. One note on this approach is that if the data files are large (or very large) loading them eagerly as we do here may impact the site's available memory and start up time. For our project we noticed no noticeable impact to either memory or start up time. That said, the total size of both files on disk is only 132KB.

There are many public lists of disposable email domains. We found this one to be regularly updated and use it here https://github.com/disposable/disposable .

GibberishClassifierService

The GibberishClassifierService is used to determine whether a message is considered gibberish. The service uses a list of common English words to check whether the content of a message is meaningful or nonsensical. In our case we only return IsGibberish = True if more that 50% of the words in the submitted message are classified as gibberish. This can easily be adjusted to fit other needs. (In our business we use many words not in the 10,000 most common.)

GibberishClassifierService.cs

using Microsoft.AspNetCore.Hosting;
using System.Text.RegularExpressions;

namespace Skrift.Io.Services
{
    public class GibberishClassifierService: IGibberishClassifierService
    {
        private HashSet<string> _englishWords;
        private readonly IWebHostEnvironment _environment;

        public GibberishClassifierService(IWebHostEnvironment webHostEnvironment)
        {
            _environment = webHostEnvironment;
            _englishWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            LoadWordList();
        }

        private void LoadWordList()
        {
            var filePath = Path.Combine(_environment.WebRootPath, "google-10000-english-usa.conf");

            if (File.Exists(filePath))
            {
                var words = File.ReadAllLines(filePath).Where(line => !string.IsNullOrWhiteSpace(line));
                _englishWords = new HashSet<string>(words, StringComparer.OrdinalIgnoreCase);
            }
        }

        public bool IsGibberish(string input)
        {
            // Check if input has repeated characters pattern (e.g., "asdfasdf")
            if (Regex.IsMatch(input, "^(.)\1+$"))
                return true;

            // Split input into words (assumes space-separated; adjust for other delimiters)
            var words = input.Split(' ');

            if (words.Length == 0)
                return false; // Handle case where input is empty

            // Keep track of how many gibberish words we found
            int gibberishCount = 0;

            // Check if each word is in the word list
            foreach (var word in words)
            {
                if (!_englishWords.Contains(word.ToLower()))
                    gibberishCount++; // Found a word not in the list
            }

            // Return true if more than half of the words are gibberish, otherwise false
            return gibberishCount > words.Length / 2;
        }
    }
}

The LoadWordList() method reads a file (google-10000-english-usa.conf) containing a list of common English words. This pattern allows the classifier to compare input words against a predefined dictionary to determine if the input is meaningful. Additionally, the IsGibberish() method uses a regex pattern to identify repeated character sequences (eg, asdfasdf), which are common in gibberish inputs.

Registering the Workflows

To make these workflows available within the Umbraco Forms UI, you need to register the workflow types in the Umbraco DI container. You can add the following in your Composer.

CustomWorkflowComposer.cs

 

public class CustomWorkflowComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.WithCollectionBuilder<WorkflowCollectionBuilder>()
            .Add<GibberishWorkflow>()
            .Add<DisposableEmailWorkflow>();
    }
}

This registration ensures that both workflows appear as options within Umbraco Forms and can be selected for any form that requires them.

 

Registering Services

The services (DisposableEmailService and GibberishClassifierService) can be registered in two different ways:

1. Registering in Program.cs

To register the services directly in Program.cs, add the following lines.

Program.cs

using Skrift.Io.Services;

var builder = WebApplication.CreateBuilder(args);

// Register services - add these lines to your existing Program.cs
builder.Services.AddSingleton<IDisposableEmailService, DisposableEmailService>();
builder.Services.AddSingleton<IGibberishClassifierService, GibberishClassifierService>();

var app = builder.Build();

This approach is useful if you want to keep all your service registrations in one place, which makes it easier to manage dependencies as the application grows.

2. Registering in a Composer

You can also register the services using a Composer, which is the preferred approach in Umbraco to maintain modularity. Below is an example of a Composer that registers the DisposableEmailService and GibberishClassifierService.

ServiceComposer.cs

using Microsoft.Extensions.DependencyInjection;
using Skrift.Io.Services;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;

namespace Skrift.Io.Composers
{
    public class ServiceComposer : IComposer
    {
        public void Compose(IUmbracoBuilder builder)
        {
            builder.Services.AddSingleton<IDisposableEmailService, DisposableEmailService>();
            builder.Services.AddSingleton<IGibberishClassifierService, GibberishClassifierService>();
        }
    }
}

Forms Workflow Configuration

Now that we have our workflows, dependent services, and data in place when we startup our Umbraco site the services will be registered in the DI container and the workflows registered and available in the Umbraco Backoffice. From my form I navigate to the Design tab and then select Configure Workflow. I can then add my custom workflows to the appropriate form Stage and re-order as needed. In our case the Gibberish Classifier Workflow and Disposable Email Checker Workflow are added to the On Submit stage, our Send Form to Intercom Workflow (another custom workflow with an Intercom integration) is added to the On Approve stage, and finally the built-in Send Rejected Inquiries to Slack Workflow is added to the On Reject stage. This way, if a form submission is not classified as gibberish or from a disposable email it is approved and the submission is sent to our Intercom instance but if the submission is classified as gibberish or from a disposable email it is rejected and sent to a Slack channel where the team can review it for a false positive.

Next Steps

The approach here can be used for any type of Forms Workflow. Very commonly workflows are used for custom integrations such as sending form submissions to your CRM or sending response emails. The pattern for the Workflow creation remains the same but I included this specific case in hopes it might help others dealing with the same type of scenario. Let me know what you think!

Paul Sterling

Paul has been working with Umbraco since 2006, is an Umbraco MVP, and is now CTO for Mold Inspection Sciences, an environmental inspection and testing organization dedicated to helping people live and work in healthy environments. He lives in Bellingham, WA (USA) where he spends most of his spare time outdoors, usually in the rain.

comments powered by Disqus