KennyG
Aug 11, 2025
visibility 1800
star star star star star
(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

error Please login to comment.
Latest blogs
Parallel Development in Optimizely CMS SaaS: A Smarter Way to Register Components

📌 A note before you read: The approach described in this article is not a replacement for Optimizely’s recommended out-of-the-box component...

Vipin Banka | Jul 21, 2026

How I Deployed My Optimizely Content JS SDK Next.js App on Vercel (Hello Opti World)

📌 Scope: This post covers Optimizely CMS (SaaS) only, using the official content-js-sdk with Next.js 15 deployed to Vercel. This is a practitioner...

Kiran Patil | Jul 21, 2026 |

Finding Thomas Part 5 - The Closed Loop

Five weeks. Five layers. One Thomas. If you've followed this series from the start — thank you. If you're just landing here, the short version:...

Ritu Madan | Jul 20, 2026

Extending the Optimizely Product Recommendations Feed to Include Custom Product Types

A practical way to extend the Optimizely Product Recommendations catalog feed so the export scheduled job also includes custom catalog types, like...

Wojciech Seweryn | Jul 20, 2026 |