Here’s the conclusion of the marathon coding session, parsing emails in pure AL. This time, the email’s content is handled. Check out the video:

In this second part of the series on parsing email bodies with pure AL in Business Central, Erik continues building out the email parser. He tackles extracting the MIME boundary from the Content-Type header, handling multipart email bodies, base64 decoding, and discovers the fun challenge of nested multipart sections. Along the way, he debugs several issues live and arrives at a working parser that can extract both text and HTML body parts from real emails.
Picking Up from Part 1
In Part 1, the parser was built to handle email headers — reading each line, detecting continuation lines (those starting with spaces or tabs), and storing everything into a table. A mysterious error turned out to be caused by Business Central’s delayed optimistic insert behavior, where duplicate “Received” headers caused a bulk insert failure that appeared on a different line than expected.
Now the focus shifts to the email body itself, which requires understanding the Content-Type header and MIME boundaries.
Understanding MIME Multipart Emails
The Content-Type header determines the structure of the rest of the email. A typical multipart email looks like this:
- Headers at the top, ending with a blank line
- A
Content-Typeheader declaringmultipart/alternative(or similar) with aboundaryparameter - Each part separated by
--boundary, with its own headers (content type, encoding, etc.) - The final boundary marked with
--boundary--(trailing dashes)
For example, an email might contain both a plain text and an HTML version of the message, each base64-encoded, separated by the boundary string.
Extracting the Boundary String
The first task is to extract the boundary value from the Content-Type header. Since different headers have vastly different formats — some use semicolons, others have parentheses and keywords — a generic approach is needed for the semicolon-delimited detail values found in headers like Content-Type.
A helper function GetHeaderDetail was created to split a header value by semicolons and search for a specific detail (like “boundary”) by name:
local procedure GetHeaderDetail(var HeaderValue: Text; Detail: Text): Text
var
HeaderDetails: List of [Text];
i: Integer;
Part: Text;
begin
// Content-Type: multipart/alternative; boundary="=-yUudarCy00rzWbucCoj37g=="
HeaderDetails := HeaderValue.Split(';');
for i := 1 to HeaderDetails.Count() do begin
Part := HeaderDetails.Get(i).Trim();
if Part.ToLower().StartsWith(Detail + '=') then begin
exit(Part.Substring(10).TrimStart('"').TrimEnd('"'));
end;
end;
end;
This function splits the header value on semicolons, trims each part, checks if it starts with the desired detail name followed by an equals sign, and returns the value with surrounding quotes stripped. Erik acknowledges this is a bit “hackish” — the Substring(10) is hardcoded and works for “boundary=” but the function was later made more generic by using the detail parameter’s length.
Expanding the Data Model
The original header table was renamed to “Email Details” and expanded with a Type field (option: Header, BodyPart) and a Binary blob field to store decoded body content:
table 56400 "Email Details"
{
fields
{
field(1; Type; Option)
{
OptionMembers = Header,BodyPart;
}
field(2; HeaderKey; Text[200])
{
}
field(3; LineNo; Integer)
{ }
field(4; Value; Text[2048])
{
}
field(5; Binary; Blob)
{
}
}
keys
{
key(PK; Type, HeaderKey, LineNo)
{
Clustered = true;
}
}
}
Erik briefly addresses the option vs. enum debate: enums are extendable and shareable, but for a quick single-use case like this, an option field is perfectly fine.
Handling Simple (Non-Multipart) Emails
If no boundary is found, the email is a simple raw text email. In that case, the remaining lines are just written directly to a blob:
if Boundary.Count = 0 then begin
Header.Init();
Header.Type := Header.Type::BodyPart;
Header.HeaderKey := 'Body';
Header.Value := '<Binary>';
Header.Binary.CreateOutStream(OutS);
repeat
i += 1;
Line := Lines.Get(i);
OutS.WriteText(Line);
until i = Lines.Count();
Header.Insert();
end
The Multipart State Machine
The multipart parser is essentially a state machine with three states: Outside, Header, and Data. The parser walks through lines and transitions between states based on boundary markers and blank lines:
- Outside: Scanning for a boundary line (
--boundary). When found, transition to Header. - Header: Reading part-specific headers (Content-Type, Content-Transfer-Encoding, Content-Disposition). A blank line signals the transition to Data, and the
DataBuilderTextBuilder is cleared. - Data: Accumulating content lines into the TextBuilder. When a boundary line is encountered (either
--boundaryfor the next part or--boundary--for the final part), the accumulated data is decoded and stored.
case InsidePart of
InsidePart::Outside:
if Line = '--' + Boundary.get(Boundary.Count) then
InsidePart := InsidePart::Header;
InsidePart::Header:
begin
if (line = '') or (Line = '--' + Boundary.Get(Boundary.Count)) then begin
HeaderKey := '';
HeaderValue := '';
end else begin
HeaderKey := Line.Split(':').Get(1).Trim();
HeaderValue := Line.Split(':').Get(2).Trim();
end;
case HeaderKey.ToLower() of
'content-type':
begin
ContentType := HeaderValue.Split(';').Get(1).Trim();
if ContentType.ToLower().StartsWith('multipart') then begin
Boundary.Add(GetHeaderDetail(HeaderValue, 'boundary'));
end;
end;
'content-transfer-encoding':
Encoding := HeaderValue.Split(':').Get(1).Trim();
'content-disposition':
Filename := GetHeaderDetail(HeaderValue, 'filename');
'':
begin
InsidePart := InsidePart::Data;
Clear(DataBuilder);
end;
end;
end;
InsidePart::Data:
begin
if (Line = '--' + Boundary.Get(Boundary.Count)) or
(Line = '--' + Boundary.Get(Boundary.Count) + '--') then begin
// Done with data
InsidePart := InsidePart::Header;
Header.Init();
Header.Type := Header.Type::BodyPart;
if Filename <> '' then
Header.HeaderKey := Filename
else
Header.HeaderKey := ContentType;
Header.Value := '<Binary>';
Header.Binary.CreateOutStream(OutS);
if Encoding.ToLower() = 'base64' then
Base64.FromBase64(DataBuilder.ToText().Trim(), OutS)
else
OutS.WriteText(DataBuilder.ToText());
PartNo += 1;
Header.LineNo := PartNo;
Header.Insert();
// Handle nested boundaries
if Boundary.Count > 1 then begin
if (Line = '--' + Boundary.Get(Boundary.Count) + '--') then begin
Boundary.RemoveAt(Boundary.Count);
InsidePart := InsidePart::Outside;
end;
end;
Filename := '';
ContentType := '';
Encoding := '';
end else
DataBuilder.AppendLine(Line);
end;
end;
Base64 Decoding
When the encoding is base64, the built-in Base64 Convert codeunit is used to decode the accumulated text directly into the output stream:
if Encoding.ToLower() = 'base64' then
Base64.FromBase64(DataBuilder.ToText().Trim(), OutS)
else
OutS.WriteText(DataBuilder.ToText());
The Export Action for Verification
A simple export action was added to the page to download and inspect the decoded binary content of any body part:
action(download)
{
Caption = 'Export Binary';
Promoted = true;
PromotedCategory = Process;
PromotedOnly = true;
trigger OnAction()
var
InS: InStream;
begin
Rec.CalcFields(Binary);
Rec.Binary.CreateInStream(InS);
DownloadFromStream(InS, '', '', '', Rec.HeaderKey);
end;
}
Testing with the first email confirmed that both the plain text and HTML body parts were correctly extracted and decoded from base64.
Debugging Adventures
Several issues were caught and fixed during live debugging:
- Passing global variables instead of local ones to the
GetHeaderDetailfunction — a classic copy-paste oversight that returned blank results. - Blank lines not being handled in the header-reading state, which caused the parser to try splitting empty lines on colons.
- The trailing boundary marker (
--boundary--with dashes on both sides) wasn’t being detected, so the parser never recognized the end of the last part. Adding a check for the double-dash suffix fixed this. - Variable cleanup between parts — resetting
Filename,ContentType, andEncodingafter each part to prevent data from one part bleeding into the next.
The Nested Multipart Surprise
Testing with a third, larger email (~17,000 lines) revealed a new challenge: nested multipart content. The email’s top-level Content-Type was multipart/related with one boundary, but the first part inside it was itself a multipart/alternative with a different boundary.
This required treating the boundary as a stack (using a List of [Text]) rather than a single value. When a new multipart section is encountered inside a part, the new boundary is pushed onto the stack. When a closing boundary marker (--boundary--) is found, the boundary is popped off:
if ContentType.ToLower().StartsWith('multipart') then begin
Boundary.Add(GetHeaderDetail(HeaderValue, 'boundary'));
end;
// When ending a part:
if Boundary.Count > 1 then begin
if (Line = '--' + Boundary.Get(Boundary.Count) + '--') then begin
Boundary.RemoveAt(Boundary.Count);
InsidePart := InsidePart::Outside;
end;
end;
Summary
By the end of Part 2, the parser can successfully:
- Extract all email headers from the raw text
- Identify and extract the MIME boundary from the Content-Type header
- Handle simple (non-multipart) emails
- Parse multipart emails using a three-state state machine
- Decode base64 content using Business Central’s built-in
Base64 Convertcodeunit - Store decoded body parts as blobs for inspection and download
- Handle nested multipart boundaries using a stack
The code is roughly 200 lines of AL and admittedly has room for cleanup and more robust error handling. The discovery of nested multipart structures hints at further complexity to tackle in a potential Part 3. As Erik notes, this is the kind of challenge you face when working in an environment without access to the rich ecosystem of third-party MIME parsing libraries available in other platforms — but it’s entirely solvable with pure AL.