Importing and parsing an email with pure AL code

For a project, I needed to parse a raw email (EML) inside Business Central, as there’s no library for this, I started the process of trying to parse an email file. Check out the video:

https://youtu.be/_FLS4ATgKyg

In this first part of a two-part series, Erik Hougaard walks through the fundamentals of email file structure and demonstrates how to import and parse email headers using pure AL code in Business Central. Starting from scratch, he reads an EML file as a text stream, splits it into lines, and extracts header key-value pairs — handling multi-line headers and duplicate keys along the way. The video is a live coding session complete with debugging, an accidental infinite loop, and a Docker crash.

What Is an Email, Really?

Before writing any code, it’s important to understand what we’re working with. At its core, every email is just a plain text file with specific formatting. This has been true since the 1970s — nothing has fundamentally changed at the protocol level.

An email file (often saved with a .eml extension) has two main sections separated by a single blank line:

  1. Headers — Key-value pairs like From:, To:, Date:, Content-Type:, etc.
  2. Body — The actual content of the email (text, HTML, attachments, etc.)

For example, in the test email Erik uses, line 42 is blank — everything above it is headers, and everything below is the body content.

Understanding Email Headers

Headers follow a simple Key: Value format, but there’s a wrinkle inherited from the 1970s: lines shouldn’t be too long. When a header value exceeds the line length limit, it wraps to the next line, which starts with a space or tab character. This is called a “folded” header.

For example, a Received header might look like this in the raw file:

Received: from mail.example.com
	by inbox.example.com
	for <user@example.com>

The lines starting with a tab or space are continuations of the Received header, not new headers. This is a key reason Erik chose to load all lines into a list rather than processing them one at a time with a stream reader — having random access to lines makes it easier to handle these continuation lines.

Setting Up the Project

The project is a simple Business Central extension with a table to store parsed email details and a list page to display them. Here’s the table definition:

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;
        }
    }
}

The Type field distinguishes between headers and body parts (which will be used in Part 2). The LineNo field was added after Erik discovered that some header keys (like Received) can appear multiple times in an email — using the line number as part of the primary key solves the duplicate key problem.

Reading the Email into Memory

The first step is to upload the EML file, read the entire content into a text variable, and split it into individual lines:

LF[1] := 13;
LF[2] := 10;
Tab[1] := 9;
InS.Read(EmailTxt);
Lines := EmailTxt.Split(LF);

The line feed variable LF is set to carriage return (character 13) followed by newline (character 10) — the standard line ending in email files (CRLF). The Tab character (ASCII 9) is used later to detect folded header lines.

A quick test confirmed this works: calling StrLen(EmailTxt) on the test email returned approximately 57,000 characters, matching the file size.

Parsing the Headers

The header parsing loop iterates through the lines, stopping when it hits the first blank line (the separator between headers and body). Here’s the core parsing logic:

Header.DeleteAll();
i := 0;
repeat
    i += 1;
    Line := Lines.Get(i);
    if Line <> '' then begin
        if Line.StartsWith(' ') or Line.StartsWith(Tab) then begin
            // This is an extension to the previous header line
            Header.Value += ' ' + Line.Trim();
            Header.Modify();
        end else begin
            HeaderKey := Line.Substring(1, Line.IndexOf(':') - 1);
            HeaderValue := Line.Substring(Line.IndexOf(':') + 1).Trim();
            Header.Init();
            Header.Type := Header.Type::Header;
            Header.HeaderKey := HeaderKey;
            Header.Value := HeaderValue;
            Header.LineNo := i;
            Header.Insert();
        end;
    end;
until Line = '';

The logic handles three cases for each line:

  • Blank line — Stop parsing; we’ve reached the end of the headers.
  • Line starts with space or tab — This is a continuation of the previous header. Append the trimmed content to the current header’s value (with a space) and modify the record.
  • Any other line — This is a new header. Split it at the first colon to extract the key and value, then insert a new record.

Issues Encountered Along the Way

Erik ran into several issues during the live coding session that are worth noting:

The infinite loop: The initial version of the repeat loop forgot to increment i, creating an infinite loop that required killing the Docker container running the Business Central service. A good reminder to always double-check loop variables!

Forgetting to assign record fields: After extracting HeaderKey and HeaderValue into local variables, Erik initially forgot to assign them to the actual record fields (Header.HeaderKey and Header.Value) before calling Header.Insert(). The debugger quickly revealed empty records being inserted.

Duplicate header keys: The Received header commonly appears multiple times in an email (once for each mail server that handled the message). The original primary key of just HeaderKey caused insert failures. Adding LineNo to the primary key resolved this.

Preview: What Comes Next in Part 2

With headers parsed, the body is the next challenge — and it’s significantly more complex. Erik gives a brief preview of what’s ahead:

Modern emails use MIME (Multipurpose Internet Mail Extensions) to structure their body content. The Content-Type header specifies the format, and for multi-part emails, it includes a boundary string that acts as a separator between different content sections.

Each section within the body can have its own headers (like Content-Type and Content-Transfer-Encoding) followed by a blank line and then the actual data — often Base64-encoded. Sections can contain text, HTML, images, or file attachments.

Looking at the source code, we can see that Erik has already built out the full body parsing logic for Part 2, including handling nested multipart boundaries (using a boundary stack), Base64 decoding, and extracting filenames from content disposition headers.

The Complete Page with Import Logic

Here’s the full page object that ties everything together, including the import action and the body parsing that will be covered in Part 2:

page 56400 "Test Email"
{
    PageType = List;
    ApplicationArea = all;
    SourceTable = "Email Details";

    layout
    {
        area(Content)
        {
            repeater(rep)
            {
                field(Type; Rec.Type)
                {
                    Width = 10;
                }
                field(HeaderKey; Rec.HeaderKey)
                {
                    Width = 10;
                }
                field(Value; Rec.Value)
                { }
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(Test)
            {
                Caption = 'Import';
                Promoted = true;
                PromotedCategory = Process;
                PromotedOnly = true;
                trigger OnAction()
                var
                    InS: InStream;
                begin
                    if UploadIntoStream('', InS) then
                        ImportAndParse(InS);
                end;
            }
            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;
            }
        }
    }

    local procedure ImportAndParse(InS: InStream)
    var
        Header: Record "Email Details";
        Base64: Codeunit "Base64 Convert";
        EmailTxt: Text;
        Lines: List of [Text];
        LF: Text[2];
        i: Integer;
        Line: Text;
        Tab: Text[1];
        HeaderKey: Text;
        HeaderValue: Text;
        Boundary: List of [Text];
        OutS: OutStream;
        InsidePart: Option Outside,Header,Data;
        Filename: Text;
        Encoding: Text;
        ContentType: Text;
        DataBuilder: TextBuilder;
        PartNo: Integer;
    begin
        LF[1] := 13;
        LF[2] := 10;
        Tab[1] := 9;
        InS.Read(EmailTxt);
        Lines := EmailTxt.Split(LF);

        Header.DeleteAll();
        i := 0;
        repeat
            i += 1;
            Line := Lines.Get(i);
            if Line <> '' then begin
                if Line.StartsWith(' ') or Line.StartsWith(Tab) then begin
                    Header.Value += ' ' + Line.Trim();
                    Header.Modify();
                end else begin
                    HeaderKey := Line.Substring(1, Line.IndexOf(':') - 1);
                    HeaderValue := Line.Substring(Line.IndexOf(':') + 1).Trim();
                    Header.Init();
                    Header.Type := Header.Type::Header;
                    Header.HeaderKey := HeaderKey;
                    Header.Value := HeaderValue;
                    Header.LineNo := i;
                    Header.Insert();
                end;
            end;
        until Line = '';

        // Body parsing continues in Part 2...
    end;

    local procedure GetHeaderDetail(var HeaderValue: Text; Detail: Text): Text
    var
        HeaderDetails: List of [Text];
        i: Integer;
        Part: Text;
    begin
        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;
}

Summary

In this first part, Erik demonstrated that parsing emails in pure AL is entirely feasible, even without external libraries. The key takeaways are:

  • Every email is fundamentally a plain text file with headers separated from the body by a blank line.
  • Headers follow a Key: Value format but can span multiple lines using leading whitespace (folding).
  • Some headers like Received can appear multiple times, so your data model needs to accommodate duplicates.
  • Loading all lines into a List of [Text] provides flexibility for look-ahead parsing that stream-based line reading doesn’t offer.
  • The AL debugger is your best friend when things go wrong — and things will go wrong (infinite loops, missing field assignments, duplicate keys).

Part 2 will tackle the more complex challenge of parsing the email body, including MIME multipart content, boundary detection, and Base64 decoding of attachments.