Take the community feedback survey now.

KennyG
Aug 11, 2025
  454
(0 votes)

Sending Email with Attachments with Optimizely Forms in 2025

Most articles on adding attachments to Optimizely Forms emails are from around 2019 and don’t cover the most recent versions. This post walks through my 2025 implementation and the lessons learned. Your mileage may vary.


The Problem

Optimizely Forms’ built-in SendEmailAfterSubmissionActor sends clean, simple emails after a form is submitted. When you use the File Upload Form Element Block, the actor includes links in the email to where files are stored in the CMS.

That’s fine for internal users with CMS access. But if your recipients are external — customers, partners, vendors — those links won’t work for them.
What I needed was:

  • Direct access to the MimeMessage object.

  • Ability to attach uploaded files directly to the outgoing email.


Why Subclassing Won’t Work

Optimizely’s SendEmailAfterSubmissionActor only allows limited subclassing.
Many of the key operations are private, not protected, and MimeMessage is never handed back for customization.

That means no way to simply “hook in” and add attachments — which is why I opted to:

  1. Decompile the original actor.

  2. Copy the logic wholesale.

  3. Insert my changes where needed.


The Approach

The original actor:

  • Sends plain text or HTML emails (TextPart only).

  • Has no concept of multipart MIME or file attachments.

The modified actor:

  • Builds a Multipart("mixed") message.

  • Adds the original body as a TextPart.

  • Calls a new helper method AddFileAttachments(multipart), to append uploaded files.

  • Resolves uploaded files from CMS blobs, determines their content type, and attaches them to the outgoing email.


Attachments Support

Attachment handling includes:

  • Detecting file upload fields from the form submission (FriendlyNameInfo and field naming patterns like file, upload, attachment).

  • Resolving blob references via IBlobFactory and IUrlResolver.

  • Determining content type based on file extension.

  • Embedding attachments as MimePart instances with base64 encoding.


Dependency Additions

To make this work, I added:

  • IFormRepository – retrieve form field metadata.

  • IBlobFactory & IUrlResolver – fetch binary data for uploaded files.

  • A DetermineContentType helper – ensures attachments have correct MIME types.


Body Construction Changes

Original
Used:

message.Body = new TextPart(subtype) { Text = this.RewriteUrls(content) };

Modified
Wraps the body in a Multipart("mixed") so it can contain both the body and attachments:

var multipart = new Multipart("mixed");
multipart.Add(new TextPart(subtype) { Text = this.RewriteUrls(content) });
this.AddFileAttachments(multipart);
message.Body = multipart;

Key New Methods

Attachment Processing

protected virtual void AddFileAttachments(Multipart multipart)
{
    var friendlyNameInfos = _formRepository.Service.GetFriendlyNameInfos(this.FormIdentity);

    foreach (var submissionDataItem in this.SubmissionData.Data)
    {
        var friendlyNameInfo = friendlyNameInfos
            .FirstOrDefault(f => string.Equals(f.ElementId, submissionDataItem.Key, StringComparison.OrdinalIgnoreCase));

        if (friendlyNameInfo != null && IsFileUploadField(friendlyNameInfo, submissionDataItem.Value))
        {
            var fileUploads = submissionDataItem.Value.ToString().Split("|");

            foreach (var fileUpload in fileUploads)
            {
                AttachFileFromBlob(multipart, fileUpload, friendlyNameInfo.FriendlyName);
            }
        }
    }
}

Note: If the form allows multiple uploads, they come through as a single string separated by |.


File Attachment Helper

protected virtual void AttachFileFromBlob(Multipart multipart, string blobIdentifier, string friendlyName)
{
    Blob blob = null;
    IContent contentData = _urlResolver.Service.Route(new UrlBuilder(blobIdentifier));

    if (contentData is MediaData media)
    {
        blob = _blobFactory.Service.GetBlob(media.BinaryData.ID);
    }

    if (blob != null)
    {
        var memoryStream = new MemoryStream();
        blob.OpenRead().CopyTo(memoryStream);
        memoryStream.Position = 0;

        var fileName = blobIdentifier.Split(new[] { "#@" }, StringSplitOptions.None).Last();
        var contentType = DetermineContentType(blob, fileName);

        multipart.Add(new MimePart(contentType)
        {
            Content = new MimeContent(memoryStream, ContentEncoding.Default),
            ContentDisposition = new ContentDisposition(ContentDisposition.Attachment),
            ContentTransferEncoding = ContentEncoding.Base64,
            FileName = fileName
        });
    }
}

The original filename is appended to the identifier after #@ — this extracts it for the email attachment.


Determine Content Type Helper

    protected virtual string DetermineContentType(Blob blob, string fileName)
    {
        // Try to get content type from blob
        // This might be available in blob metadata depending on your implementation

        // Fall back to determining from file extension
        string extension = Path.GetExtension(fileName)?.ToLowerInvariant();

        return extension switch
        {
            ".pdf" => "application/pdf",
            ".doc" => "application/msword",
            ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            ".xls" => "application/vnd.ms-excel",
            ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            ".jpg" or ".jpeg" => "image/jpeg",
            ".png" => "image/png",
            ".gif" => "image/gif",
            ".txt" => "text/plain",
            ".csv" => "text/csv",
            ".zip" => "application/zip",
            ".xml" => "application/xml",
            ".json" => "application/json",
            _ => "application/octet-stream"
        };
    }

SendMessage() Changes

Original:

MimeMessage message = new MimeMessage();
            
message.Subject = str1;
string subtype = this._sendMessageInHTMLFormat ? "html" : "plain";
message.Body = (MimeEntity) new TextPart(subtype, new object[1]
{
    (object) this.RewriteUrls(content)
});

Modified:

MimeMessage message = new MimeMessage();
message.Subject = str1;

// Create multipart message to support attachments
var multipart = new Multipart("mixed");

string subtype = this._sendMessageInHTMLFormat ? "html" : "plain";
multipart.Add(new TextPart(subtype, new object[1]
{
       (object) this.RewriteUrls(content)
}));

// Add file attachments
this.AddFileAttachments(multipart);

message.Body = multipart;

Wiring It Up

To replace the out-of-the-box actor, you’ll likely need to do it in your Dependency Injection setup.
In my case, I didn’t replace it globally — we already had a subclassed actor for applying different HTML templates, so I had that subclass inherit from the new replacement instead of the original.


Takeaways

  • Original actor: Simple, reliable, zero attachment support.

  • Modified actor: Same foundation, plus multipart MIME and attachment handling.

If you need to extend built-in actors in Optimizely Forms beyond what the public API offers, sometimes you’ll have to work at the code level.

Caution: After any update to the Forms package, check if SendEmailAfterSubmissionActor has changed — you may need to re-sync your version or take advantage of new core features.

Have any thoughts or opinions? Let me know in the comments!

Aug 11, 2025

Comments

Please login to comment.
Latest blogs
How Optimizely MCP Learns Your CMS (and Remembers It)

In Part 1, I introduced the “discovery-first” idea—an MCP that can plug into any SaaS CMS and learn how it’s structured on its own. This post gets...

Johnny Mullaney | Oct 20, 2025 |

Building a Custom Payment in Optimizely Commerce 14 (with a simple “Account” method)

This post outlines a simplified method for integrating Account payments into Commerce 14, detailing a minimal working path without extensive...

Francisco Quintanilla | Oct 17, 2025 |

Going Headless: Making the Right Architectural Choices

Earlier this year, I began a series of articles about headless architecture: Going Headless: Optimizely Graph vs Content Delivery API Going Headles...

Michał Mitas | Oct 17, 2025

How to Use IPlacedPriceProcessor in Optimizely Commerce to Preserve Custom Line Item Prices (Donation Example)

Optimizely Commerce 12+ automatically validates line item prices based on catalog data, which can overwrite user-entered prices for donations or...

Francisco Quintanilla | Oct 15, 2025 |