Skip to main content

Command Palette

Search for a command to run...

Automating FortiMail Monthly Statistics Extraction with JavaScript, AngularJS and Edge DevTools

Building a state-aware browser automation toolkit for dynamic AngularJS charts inside an authenticated Citrix session

Updated
30 min readView as Markdown
S
UK Data and Automation Professional | Power Apps | Power Automate | Power BI | PowerShell | Excel | Power Query | Driving Reporting Excellence and Process Transformation

FortiMail provides useful mail statistics through interactive dashboards, but extracting consolidated information across multiple domains can become repetitive when the required data is only available through dynamically refreshed charts.

In my case, I needed to extract the previous completed month’s statistics for more than 60 configured domains across three traffic directions:

  • Incoming

  • Outgoing

  • Unknown

The original process required selecting every domain manually, changing the traffic direction, waiting for a 12-month chart to reload, hovering over the relevant month and recording the displayed value.

I replaced that process with a JavaScript automation toolkit that runs as a Microsoft Edge DevTools Snippet inside an already authenticated Citrix session.

The completed toolkit:

  • Processes a predefined list of domains

  • Calculates the previous completed calendar month

  • Searches and selects each domain

  • Changes the FortiMail direction at AngularJS scope level

  • Waits for the chart to finish refreshing

  • Extracts and totals the required monthly value

  • Distinguishes confirmed no-data responses from failures

  • Maintains progress information during execution

  • Produces a consolidated CSV automatically

  • Preserves failed cells as blank for later investigation

This article explains the technical design behind the solution.

Important: All snippets in this article are sanitised examples. Internal selectors, domain names, server details and environment-specific configuration have been replaced with placeholders.

The Reporting Problem

The FortiMail interface presented the required information in an interactive Count, 12 Months chart.

For every domain, the manual workflow was:

  1. Open the domain selector.

  2. Search for the domain.

  3. Select the matching entry.

  4. Wait for the chart to update.

  5. Select the Incoming direction.

  6. Capture the previous month’s value.

  7. Repeat for Outgoing.

  8. Repeat for Unknown.

  9. Move to the next domain.

  10. Consolidate the results manually.

This presented several automation challenges:

  • The domain selector was dynamically rendered.

  • The search input only existed while the dropdown was open.

  • The dropdown did not behave like a native HTML <select>.

  • FortiMail used AngularJS state to control the chart.

  • Chart refresh timing varied.

  • Existing chart values could remain visible during loading.

  • A no-data response had to be distinguished from a failed request.

  • Multiple chart series had to be totalled for the required month.

  • Similar domain names made partial searching risky.

A fixed series of clicks and delays would not be reliable enough. The implementation needed to validate the state of the application at each stage.

High-Level Architecture

The toolkit is divided into several logical components:

Configuration
    |
    v
Preflight validation
    |
    v
Domain selection
    |
    v
Direction switching
    |
    v
Chart refresh detection
    |
    v
Previous-month extraction
    |
    v
Failure classification
    |
    v
Progress state
    |
    v
CSV generation and download

The public interface is exposed through a single global object:

window.FortiMailToolkit = {
    preflight: preflight,
    run: run,
    stop: stop
};

This makes normal operation simple:

FortiMailToolkit.preflight();
await FortiMailToolkit.run();

The user can also request a controlled stop:

FortiMailToolkit.stop();

1. Encapsulating the Toolkit

The implementation uses an Immediately Invoked Function Expression, commonly called an IIFE.

(function (global) {
    "use strict";

    // Private configuration and functions live here.

    global.FortiMailToolkit = {
        preflight: preflight,
        run: run
    };
})(window);

This pattern provides two benefits:

  1. Internal functions and variables do not unnecessarily pollute the global browser scope.

  2. Only the intended public functions are exposed through FortiMailToolkit.

The window object is passed into the function as global, which makes the dependency explicit.


2. Defining Configuration Separately

Timing values and output settings should not be scattered throughout the code.

var DEFAULTS = {
    searchWaitMilliseconds: 2000,
    afterDomainClickMilliseconds: 2000,
    chartSettleMilliseconds: 2000,
    directionPauseMilliseconds: 1500,
    chartTimeoutMilliseconds: 45000,
    filePrefix: "FortiMail_R1"
};

At runtime, optional user settings are merged into the defaults:

var settings = Object.assign(
    {},
    DEFAULTS,
    userSettings || {}
);

This allows an operator to increase the chart timeout without modifying the source:

await FortiMailToolkit.run({
    chartTimeoutMilliseconds: 60000,
    chartSettleMilliseconds: 3000
});

Keeping configuration separate makes the automation easier to maintain across environments with different performance characteristics.


3. Maintaining a Controlled Target List

Instead of scraping every visible value from the selector, the production workflow uses a reviewed target list.

For publication, the list can be represented as follows:

var TARGET_DOMAINS = [
    "mail.example-one.test",
    "apps.example-two.test",
    "service.example-three.test"
];

Each value is then converted into a consistent internal structure:

var selectedDomains = TARGET_DOMAINS.map(function (domain) {
    return {
        Domain: domain,
        SourcePath: "TARGET_DOMAINS",
        RawItem: domain
    };
});

Using a controlled list has several advantages:

  • The reporting scope is explicit.

  • Accidental system-level options can be excluded.

  • The execution order is predictable.

  • The target list can be reviewed independently.

  • Unexpected dropdown values are not automatically included.


4. Validating Domain Names Without a Fragile Regular Expression

An earlier approach used a single complex regular expression for hostname validation. That can be difficult to debug, especially when code is copied through systems that may alter escaping.

The final approach validates each hostname label separately.

function isFortiMailDomain(value) {
    var text;
    var labels;
    var finalLabel;
    var index;
    var label;

    if (typeof value !== "string") {
        return false;
    }

    text = value.trim().toLowerCase();

    if (text.length < 4 || text.length > 253) {
        return false;
    }

    if (
        text.indexOf(".") === -1 ||
        text.indexOf("/") !== -1 ||
        text.indexOf("\\") !== -1 ||
        text.indexOf(" ") !== -1 ||
        text.indexOf("\t") !== -1 ||
        text.indexOf("\n") !== -1 ||
        text === "all" ||
        text === "system" ||
        text.indexOf("--") === 0
    ) {
        return false;
    }

    labels = text.split(".");

    if (labels.length < 2) {
        return false;
    }

    finalLabel = labels[labels.length - 1];

    if (!/[a-z]/i.test(finalLabel)) {
        return false;
    }

    for (index = 0; index < labels.length; index++) {
        label = labels[index];

        if (
            label.length === 0 ||
            label.length > 63 ||
            label.charAt(0) === "-" ||
            label.charAt(label.length - 1) === "-" ||
            !/^[a-z0-9-]+$/i.test(label)
        ) {
            return false;
        }
    }

    return true;
}

This checks that:

  • The value is a string.

  • The full hostname is within the expected length.

  • At least one dot exists.

  • Spaces, paths and line breaks are absent.

  • Special selector options are excluded.

  • No label is empty.

  • Each label is no longer than 63 characters.

  • Labels do not start or finish with a hyphen.

  • Labels contain only letters, numbers and hyphens.

  • The final label contains at least one letter.

This is easier to inspect than a single large expression and avoids the failure of the entire validator because of one malformed grouping.


5. Calculating the Previous Completed Month

The reporting period must always represent the previous completed calendar month.

function getReportingMonth() {
    var currentDate;
    var previousMonthDate;
    var monthNumber;
    var monthText;

    currentDate = new Date();

    previousMonthDate = new Date(
        currentDate.getFullYear(),
        currentDate.getMonth() - 1,
        1
    );

    monthNumber = previousMonthDate.getMonth() + 1;

    monthText = monthNumber < 10
        ? "0" + monthNumber
        : String(monthNumber);

    return previousMonthDate.getFullYear() + "-" + monthText;
}

If the script is run in September 2026, the result is:

2026-08

Using the JavaScript Date constructor also handles crossing the year boundary. If the current month is January, subtracting one month correctly produces December of the previous year.

A quick test can be performed with:

console.log(getReportingMonth());

The format must match the labels supplied by the FortiMail chart. If a different FortiMail version uses labels such as Aug 2026, the formatting logic should be adapted accordingly.


6. Locating the Correct Chart Panel

The page can contain multiple reporting panels. The toolkit searches for the panel whose title contains 12 Months.

function getPanelContext() {
    var panels;
    var panelIndex;
    var index;
    var titleElement;
    var titleText;
    var panel;
    var scope;

    panels = Array.prototype.slice.call(
        document.querySelectorAll(
            "#SANITISED_ROOT_SELECTOR .panel"
        )
    );

    panelIndex = -1;

    for (index = 0; index < panels.length; index++) {
        titleElement = panels[index].querySelector(
            ".panel-title"
        );

        titleText = titleElement
            ? titleElement.innerText.trim()
            : "";

        if (titleText.indexOf("12 Months") !== -1) {
            panelIndex = index;
            break;
        }
    }

    if (panelIndex === -1) {
        throw new Error(
            "Count, 12 Months was not found."
        );
    }

    panel = panels[panelIndex];
    scope = window.angular.element(panel).scope();

    if (!scope) {
        throw new Error(
            "The FortiMail chart scope was not found."
        );
    }

    if (typeof scope.reChart !== "function") {
        throw new Error(
            "The FortiMail reChart function was not found."
        );
    }

    return {
        panel: panel,
        panelIndex: panelIndex,
        scope: scope
    };
}

The returned context contains:

  • The panel DOM element

  • The panel’s position in FortiMail’s internal arrays

  • The associated AngularJS scope

This context is reused throughout the extraction instead of repeatedly inspecting the page.

Why the panel index matters

FortiMail stores chart-related values in indexed arrays, for example:

scope.labels[panelIndex]
scope.data[panelIndex]
scope.param[panelIndex]

The automation therefore needs both the AngularJS scope and the correct panel index.


7. Working with Dynamically Rendered Elements

A normal HTML dropdown might be automated by assigning a value and dispatching a change event. The FortiMail selector was more complex because the visible options and search box were dynamically created.

A general visibility helper is useful throughout the script:

function isVisible(element) {
    var rectangle;
    var style;

    if (!element) {
        return false;
    }

    rectangle = element.getBoundingClientRect();
    style = window.getComputedStyle(element);

    return (
        rectangle.width > 0 &&
        rectangle.height > 0 &&
        style.display !== "none" &&
        style.visibility !== "hidden"
    );
}

This avoids interacting with hidden template elements or stale options that remain in the DOM.


8. Finding the Dropdown Search Input

Because the search input exists only after the selector opens, it cannot be stored during initialisation.

The toolkit searches for visible text or search inputs near the selector.

function findVisibleSearchInput() {
    var selectorElement;
    var selectorRectangle;
    var inputs;
    var candidates;
    var index;
    var input;
    var inputType;
    var rectangle;
    var horizontalDistance;
    var verticalDistance;
    var score;

    selectorElement = document.querySelector(
        "#SANITISED_ROOT_SELECTOR fd-dynamic-single-select"
    );

    if (!selectorElement) {
        return null;
    }

    selectorRectangle =
        selectorElement.getBoundingClientRect();

    inputs = document.querySelectorAll("input");
    candidates = [];

    for (index = 0; index < inputs.length; index++) {
        input = inputs[index];

        if (!isVisible(input)) {
            continue;
        }

        inputType = (input.type || "text").toLowerCase();

        if (
            inputType !== "text" &&
            inputType !== "search"
        ) {
            continue;
        }

        rectangle = input.getBoundingClientRect();

        horizontalDistance = Math.abs(
            rectangle.left - selectorRectangle.left
        );

        verticalDistance =
            rectangle.top - selectorRectangle.top;

        score = 0;

        if (horizontalDistance < 100) {
            score += 100;
        }

        if (
            verticalDistance >= 0 &&
            verticalDistance < 250
        ) {
            score += 100;
        }

        if (
            rectangle.width >= 50 &&
            rectangle.width <= 350
        ) {
            score += 25;
        }

        candidates.push({
            Element: input,
            Score: score
        });
    }

    candidates.sort(function (first, second) {
        return second.Score - first.Score;
    });

    return candidates.length
        ? candidates[0].Element
        : null;
}

This is a heuristic rather than a hard-coded dependency on one generated input ID.

The candidate score considers:

  • Horizontal proximity to the selector

  • Vertical positioning below the selector

  • A reasonable search-input width

  • Whether the input is currently visible


9. Waiting for an Element to Appear

Dynamic interfaces should be polled rather than queried only once.

function sleep(milliseconds) {
    return new Promise(function (resolve) {
        window.setTimeout(resolve, milliseconds);
    });
}

async function waitForSearchInput(
    maximumWaitMilliseconds
) {
    var startedAt;
    var input;

    startedAt = Date.now();

    while (
        Date.now() - startedAt <
        maximumWaitMilliseconds
    ) {
        input = findVisibleSearchInput();

        if (input) {
            return input;
        }

        await sleep(100);
    }

    return null;
}

This helper checks every 100 milliseconds until either:

  • A suitable search input is found, or

  • The maximum wait period expires

Polling provides a better balance than one fixed delay because a fast response can continue immediately, while a slower response still has time to complete.


10. Simulating a Browser Mouse Sequence

Some custom controls react to more than a programmatic .click().

The toolkit dispatches a realistic sequence of mouse events:

function sendMouseSequence(
    element,
    clientX,
    clientY
) {
    var options;
    var eventNames;
    var index;

    if (!element) {
        return;
    }

    options = {
        bubbles: true,
        cancelable: true,
        view: window,
        clientX: clientX,
        clientY: clientY,
        button: 0,
        buttons: 1
    };

    eventNames = [
        "mouseenter",
        "mouseover",
        "mousemove",
        "mousedown",
        "mouseup",
        "click"
    ];

    for (
        index = 0;
        index < eventNames.length;
        index++
    ) {
        element.dispatchEvent(
            new MouseEvent(
                eventNames[index],
                options
            )
        );
    }
}

This approach was used because the selector could respond differently depending on whether the click originated from the visible element, a child element or the element located at a particular screen coordinate.


11. Setting the Search Value Correctly

Assigning input.value directly does not always notify AngularJS or other front-end frameworks.

The toolkit retrieves the native setter and dispatches the relevant events.

function setSearchValue(input, value) {
    var descriptor;

    descriptor = Object.getOwnPropertyDescriptor(
        window.HTMLInputElement.prototype,
        "value"
    );

    input.focus();

    if (descriptor && descriptor.set) {
        descriptor.set.call(input, value);
    } else {
        input.value = value;
    }

    input.dispatchEvent(
        new Event("input", {
            bubbles: true
        })
    );

    input.dispatchEvent(
        new Event("change", {
            bubbles: true
        })
    );

    input.dispatchEvent(
        new KeyboardEvent("keyup", {
            bubbles: true,
            cancelable: true,
            key: "a",
            code: "KeyA",
            keyCode: 65,
            which: 65
        })
    );

    try {
        window.angular
            .element(input)
            .triggerHandler("input");

        window.angular
            .element(input)
            .triggerHandler("change");

        window.angular
            .element(input)
            .triggerHandler("keyup");
    } catch (error) {
        // Native events have already been dispatched.
    }
}

This updates both:

  • The actual DOM input value

  • The event-driven framework state listening to the control

The framework-specific calls are wrapped in a try block so that native event dispatch remains the primary mechanism.


12. Searching with the Shortest Unique Domain Fragment

Typing the full hostname for every search works, but it is not always necessary.

The toolkit calculates the shortest left-to-right fragment that uniquely identifies the target within the configured domain list.

function normalise(value) {
    return String(value || "")
        .trim()
        .toLowerCase();
}

function getUniqueSearchText(
    targetDomain,
    allDomains
) {
    var target;
    var sections;
    var sectionCount;
    var candidate;
    var matches;

    target = normalise(targetDomain);
    sections = target.split(".");

    for (
        sectionCount = 2;
        sectionCount <= sections.length;
        sectionCount++
    ) {
        candidate = sections
            .slice(0, sectionCount)
            .join(".");

        matches = allDomains.filter(function (domain) {
            return normalise(domain)
                .indexOf(candidate) !== -1;
        });

        if (
            matches.length === 1 &&
            normalise(matches[0]) === target
        ) {
            return candidate;
        }
    }

    return targetDomain;
}

Consider these domains:

var domains = [
    "alpha.apps.example.test",
    "alpha.mail.example.test",
    "beta.apps.example.test"
];

Searching only for alpha is ambiguous. Searching for alpha.apps identifies the first domain uniquely.

The full domain remains the fallback if no shorter fragment is unique.


13. Requiring an Exact Match Before Selection

Partial filtering is useful, but the selected row should still match the complete requested hostname.

function getElementText(element) {
    if (!element) {
        return "";
    }

    return (
        element.innerText ||
        element.textContent ||
        element.getAttribute("title") ||
        element.getAttribute("data-value") ||
        element.getAttribute("aria-label") ||
        ""
    ).trim();
}

function findExactFilteredEntry(domainName) {
    var target;
    var entries;
    var matches;
    var index;
    var element;
    var text;
    var clickable;

    target = normalise(domainName);

    entries = document.querySelectorAll(
        ".entry, .ui-grid-row, .ui-grid-cell"
    );

    matches = [];

    for (index = 0; index < entries.length; index++) {
        element = entries[index];

        if (!isVisible(element)) {
            continue;
        }

        text = getElementText(element);

        if (!text || text === "--System--") {
            continue;
        }

        clickable = element;

        if (
            element.classList &&
            element.classList.contains("ui-grid-cell")
        ) {
            clickable =
                element.closest(".entry") ||
                element.closest(".ui-grid-row") ||
                element;
        }

        if (normalise(text) === target) {
            matches.push({
                Element: clickable,
                Text: text
            });
        }
    }

    return matches.length ? matches[0] : null;
}

The search fragment narrows the options, while the exact-match function protects the final selection.

This separation is important:

Partial text = filtering strategy
Exact hostname = selection rule

14. Capturing a Chart Signature

A major risk in dashboard automation is reading stale data.

After a domain or direction changes, the previous chart can remain visible until the new response is processed.

To detect meaningful changes, the toolkit creates a serialised signature of the chart.

First, chart values are normalised:

function getNumericValue(value) {
    var convertedValue;
    var numericValue;

    convertedValue = value;

    if (
        convertedValue !== null &&
        typeof convertedValue === "object"
    ) {
        if (convertedValue.y !== undefined) {
            convertedValue = convertedValue.y;
        } else if (
            convertedValue.value !== undefined
        ) {
            convertedValue = convertedValue.value;
        } else if (
            convertedValue.count !== undefined
        ) {
            convertedValue = convertedValue.count;
        }
    }

    numericValue = Number(convertedValue);

    return Number.isFinite(numericValue)
        ? numericValue
        : 0;
}

The signature can then include chart labels, normalised data and the no-data state:

function getChartSignature(context) {
    var labels;
    var chartData;
    var normalisedData;

    labels = Array.prototype.slice.call(
        context.scope.labels[
            context.panelIndex
        ] || []
    );

    chartData = Array.prototype.slice.call(
        context.scope.data[
            context.panelIndex
        ] || []
    );

    normalisedData = chartData.map(
        function (categoryValues) {
            return Array.prototype.slice
                .call(categoryValues || [])
                .map(getNumericValue);
        }
    );

    return JSON.stringify({
        Labels: labels,
        Data: normalisedData,
        NoData: chartShowsNoData(context)
    });
}

Before initiating a refresh:

var previousSignature =
    getChartSignature(context);

After initiating the refresh, the current signature can be compared with the previous value:

var chartChanged =
    getChartSignature(context) !==
    previousSignature;

This does not rely solely on time. It checks whether the underlying chart state has changed.


15. Detecting a Confirmed No-Data State

A no-data response must be detected carefully because it will be exported as zero.

The toolkit checks both AngularJS state and visible panel text.

function chartShowsNoData(context) {
    var parameter;
    var body;
    var text;

    parameter =
        context.scope.param &&
        context.scope.param[context.panelIndex]
            ? context.scope.param[
                context.panelIndex
            ]
            : null;

    if (
        parameter &&
        (
            parameter.noData === true ||
            parameter.noData === 1 ||
            parameter.noData === "true"
        )
    ) {
        return true;
    }

    body = context.panel.querySelector(
        ".panel-body"
    );

    text = body
        ? (
            body.innerText ||
            body.textContent ||
            ""
        ).trim().toLowerCase()
        : "";

    return (
        text === "no data!" ||
        text === "no data" ||
        text.indexOf("no data!") !== -1
    );
}

Checking two sources provides more resilience than relying only on visible text or only on one internal property.

However, zero should be assigned only after the refresh has reached a settled state. Otherwise, a temporary empty panel could be mistaken for a genuine no-data result.


16. Waiting for the Chart Intelligently

The chart wait function combines several signals:

  • Whether loading was observed

  • Whether retrieval has finished

  • Whether the reporting month exists

  • Whether chart data is available

  • Whether the chart signature changed

  • Whether a confirmed no-data state exists

  • Whether the fallback wait period has elapsed

  • Whether the overall timeout has expired

A simplified version looks like this:

async function waitForChart(
    context,
    previousSignature,
    reportingMonth,
    settings
) {
    var startedAt;
    var loadingObserved;

    startedAt = Date.now();
    loadingObserved = false;

    while (
        Date.now() - startedAt <
        settings.chartTimeoutMilliseconds
    ) {
        var statuses =
            context.scope
                .isRetrievingStatisticRealtimeDone;

        var retrievalStatus = statuses
            ? statuses[context.panelIndex]
            : undefined;

        if (retrievalStatus === false) {
            loadingObserved = true;
        }

        var retrievalFinished =
            retrievalStatus !== false;

        var noData =
            chartShowsNoData(context);

        var labels =
            context.scope.labels[
                context.panelIndex
            ] || [];

        var chartData =
            context.scope.data[
                context.panelIndex
            ] || [];

        var currentSignature =
            getChartSignature(context);

        var monthFound =
            Array.prototype.indexOf.call(
                labels,
                reportingMonth
            ) !== -1;

        var chartHasData =
            monthFound && chartData.length > 0;

        var chartChanged =
            currentSignature !==
            previousSignature;

        var elapsed =
            Date.now() - startedAt;

        var minimumSettleReached =
            elapsed > 1200;

        var fallbackReady =
            elapsed > 6000;

        if (
            retrievalFinished &&
            noData &&
            minimumSettleReached
        ) {
            await sleep(
                settings.chartSettleMilliseconds
            );

            return {
                HasData: false,
                NoData: true
            };
        }

        if (
            retrievalFinished &&
            chartHasData &&
            (
                loadingObserved ||
                chartChanged ||
                fallbackReady
            )
        ) {
            await sleep(
                settings.chartSettleMilliseconds
            );

            return {
                HasData: true,
                NoData: false
            };
        }

        await sleep(150);
    }

    throw new Error(
        "Timed out waiting for the 12-month chart."
    );
}

Why use a fallback?

A domain or direction can legitimately produce the same values as the previous selection.

In that case:

currentSignature === previousSignature

does not necessarily mean that the chart failed to refresh.

The fallback allows the process to continue when:

  • The expected month is present

  • Chart data exists

  • The retrieval flag is not in a loading state

  • A reasonable waiting period has passed

This avoids an infinite wait when two valid chart states happen to be identical.


17. Changing Direction Through AngularJS State

The direction mappings used by the FortiMail controller were:

var directions = [
    {
        Name: "Incoming",
        Value: 1,
        Column: "R1 Inbound"
    },
    {
        Name: "Outgoing",
        Value: 2,
        Column: "R1 Outbound"
    },
    {
        Name: "Unknown",
        Value: 0,
        Column: "R1 Unknown"
    }
];

The toolkit updates the direction in AngularJS state and calls the existing chart refresh function:

async function selectDirection(
    context,
    directionValue,
    reportingMonth,
    settings
) {
    var previousSignature;

    previousSignature =
        getChartSignature(context);

    context.scope.$apply(function () {
        if (
            !context.scope.param[
                context.panelIndex
            ]
        ) {
            context.scope.param[
                context.panelIndex
            ] = {};
        }

        context.scope.param[
            context.panelIndex
        ].direction = directionValue;

        if (context.scope.fobj) {
            context.scope.fobj.m_direction =
                directionValue;
        }

        context.scope.reChart(
            context.panelIndex
        );
    });

    return await waitForChart(
        context,
        previousSignature,
        reportingMonth,
        settings
    );
}

Using $apply() informs AngularJS that the model has changed and allows the normal digest cycle to process the update.

This is more direct than attempting to locate and click multiple direction controls for every domain.

It is also highly implementation-specific. Internal AngularJS models are not public APIs and can change after a FortiMail interface update.


18. Extracting and Totalling the Required Month

The chart can contain multiple series. The required monthly total is calculated by finding the month index and summing the corresponding value from every series.

function readPreviousMonthTotal(
    context,
    reportingMonth
) {
    var labels;
    var monthIndex;
    var chartData;
    var total;
    var categoryIndex;
    var categoryValues;

    if (chartShowsNoData(context)) {
        return 0;
    }

    labels = Array.prototype.slice.call(
        context.scope.labels[
            context.panelIndex
        ] || []
    );

    monthIndex = labels.indexOf(
        reportingMonth
    );

    if (monthIndex === -1) {
        throw new Error(
            reportingMonth +
            " is not present in the chart."
        );
    }

    chartData = Array.prototype.slice.call(
        context.scope.data[
            context.panelIndex
        ] || []
    );

    if (chartData.length === 0) {
        return 0;
    }

    total = 0;

    for (
        categoryIndex = 0;
        categoryIndex < chartData.length;
        categoryIndex++
    ) {
        categoryValues =
            chartData[categoryIndex] || [];

        total += getNumericValue(
            categoryValues[monthIndex]
        );
    }

    return total;
}

The logic does not assume that the first data series represents the complete total.

Conceptually:

If the chart structure changes, the data-series assumptions should be revalidated before continuing to use this calculation.


19. Distinguishing No Data from Failure

The toolkit applies three possible states to each output cell.

State

CSV value

Failure log

Chart returned data

Numeric total

No

FortiMail confirmed no data

0

No

Selection, loading or extraction failed

Blank

Yes

The relevant implementation is:

try {
    var loadResult = await selectDirection(
        context,
        direction.Value,
        reportingMonth,
        settings
    );

    if (loadResult.NoData) {
        row[direction.Column] = 0;
    } else {
        row[direction.Column] =
            readPreviousMonthTotal(
                context,
                reportingMonth
            );
    }
} catch (error) {
    row[direction.Column] = "";

    failures.push({
        Domain: domainName,
        Direction: direction.Name,
        Error: String(
            error.message || error
        )
    });
}

This distinction is critical for data quality.

If all failures were converted to zero, the generated report could incorrectly suggest that no mail traffic occurred. Leaving the cell blank makes the uncertainty visible.


20. Running the Nested Extraction Loop

The main workflow uses one outer loop for domains and one inner loop for directions.

for (
    var domainIndex = 0;
    domainIndex < domainNames.length;
    domainIndex++
) {
    var domainName =
        domainNames[domainIndex];

    var row = {
        Domain: domainName,
        "R1 Inbound": "",
        "R1 Outbound": "",
        "R1 Unknown": ""
    };

    try {
        await selectDomain(
            context,
            domainName,
            domainNames,
            reportingMonth,
            settings
        );

        for (
            var directionIndex = 0;
            directionIndex < directions.length;
            directionIndex++
        ) {
            var direction =
                directions[directionIndex];

            try {
                var loadResult =
                    await selectDirection(
                        context,
                        direction.Value,
                        reportingMonth,
                        settings
                    );

                row[direction.Column] =
                    loadResult.NoData
                        ? 0
                        : readPreviousMonthTotal(
                            context,
                            reportingMonth
                        );
            } catch (directionError) {
                row[direction.Column] = "";

                failures.push({
                    Domain: domainName,
                    Direction: direction.Name,
                    Error: String(
                        directionError.message ||
                        directionError
                    )
                });
            }

            await sleep(
                settings
                    .directionPauseMilliseconds
            );
        }
    } catch (domainError) {
        failures.push({
            Domain: domainName,
            Direction: "Domain selection",
            Error: String(
                domainError.message ||
                domainError
            )
        });
    }

    results.push(row);
}

The direction-level try...catch allows the process to retain successful values even if one direction fails.

For example:

Domain A
Inbound: 2500
Outbound: blank
Unknown: 12

The row remains part of the output, and only the outbound request is added to the failure log.


21. Preserving Live Progress

A long browser-based process should expose its current state.

function saveProgress(status) {
    window.fortiMailConsolidatedExport = {
        Status: status,
        ReportingMonth: reportingMonth,
        TotalDomains: domainNames.length,
        CompletedDomains: results.length,
        Results: results.slice(),
        Failures: failures.slice(),
        Csv: buildCsv(results)
    };
}

Progress can then be inspected at any point:

fortiMailConsolidatedExport

To view recently completed rows:

console.table(
    fortiMailConsolidatedExport.Results.slice(-5)
);

To inspect failures:

console.table(
    fortiMailConsolidatedExport.Failures
);

The use of .slice() creates a shallow copy of each array for the published progress state.


22. Supporting a Controlled Stop

Immediately terminating JavaScript in the middle of a selection or chart refresh could leave the interface in an uncertain state.

The toolkit instead sets a stop flag:

var stopRequested = false;

window.stopFortiMailExport = function () {
    stopRequested = true;

    console.warn(
        "Stop requested. The export will stop " +
        "after the current operation."
    );
};

The loop checks the flag between operations:

if (stopRequested) {
    break;
}

The public wrapper is:

FortiMailToolkit.stop();

This preserves completed results and allows the currently active operation to finish cleanly.


23. Escaping Values for CSV

CSV output requires correct escaping, especially if a value contains a quotation mark, comma or line break.

function escapeCsv(value) {
    var text;

    text =
        value === null ||
        value === undefined
            ? ""
            : String(value);

    return (
        '"' +
        text.replace(/"/g, '""') +
        '"'
    );
}

Every value is enclosed in double quotes, and internal double quotes are doubled.

The complete CSV is then built row by row:

function buildCsv(rows) {
    var headers;
    var csvRows;

    headers = [
        "Domain",
        "R1 Inbound",
        "R1 Outbound",
        "R1 Unknown"
    ];

    csvRows = [headers];

    rows.forEach(function (row) {
        csvRows.push([
            row.Domain,
            row["R1 Inbound"],
            row["R1 Outbound"],
            row["R1 Unknown"]
        ]);
    });

    return csvRows
        .map(function (row) {
            return row
                .map(escapeCsv)
                .join(",");
        })
        .join("\r\n");
}

The resulting structure is:

"Domain","R1 Inbound","R1 Outbound","R1 Unknown"
"mail.example-one.test","1245","421","0"
"apps.example-two.test","853","","7"

A blank value indicates an extraction failure, not a confirmed zero.


24. Downloading the CSV in the Browser

The browser can generate and download the file without a server-side component.

function downloadCsv(csv, fileName) {
    var blob;
    var objectUrl;
    var link;

    blob = new Blob(
        ["\uFEFF" + csv],
        {
            type: "text/csv;charset=utf-8"
        }
    );

    objectUrl =
        URL.createObjectURL(blob);

    link = document.createElement("a");

    link.href = objectUrl;
    link.download = fileName;
    link.style.display = "none";

    document.body.appendChild(link);
    link.click();
    link.remove();

    window.setTimeout(function () {
        URL.revokeObjectURL(objectUrl);
    }, 1000);
}

The Unicode byte order mark, \uFEFF, helps spreadsheet applications recognise UTF-8 encoded content.

The object URL is revoked after the download has been triggered to release browser resources.

A month-specific filename can be generated like this:

var fileName =
    "FortiMail_R1_" +
    reportingMonth +
    "_All_Domains.csv";

Example:

FortiMail_R1_2026-08_All_Domains.csv

25. Adding a Preflight Check

The automation should fail before processing begins if the page is not ready.

function preflight() {
    var context;
    var reportingMonth;
    var availableMonths;
    var result;

    context = getPanelContext();
    reportingMonth = getReportingMonth();

    availableMonths =
        Array.prototype.slice.call(
            context.scope.labels[
                context.panelIndex
            ] || []
        );

    result = {
        Domains:
            window.fortiMailDomains.length,

        ReportingMonth:
            reportingMonth,

        MonthPresent:
            availableMonths.indexOf(
                reportingMonth
            ) !== -1 ||
            chartShowsNoData(context),

        ChartRefresh:
            typeof context.scope.reChart,

        SearchFinder:
            typeof findVisibleSearchInput
    };

    console.table([result]);

    if (result.Domains === 0) {
        throw new Error(
            "No target domains are loaded."
        );
    }

    if (!result.MonthPresent) {
        throw new Error(
            "The previous completed month " +
            "is not available in the chart."
        );
    }

    console.log(
        "READY: FortiMail exporter " +
        "preflight passed."
    );

    return result;
}

This confirms that:

  • Domains are loaded.

  • The reporting panel exists.

  • An AngularJS scope is available.

  • The refresh function exists.

  • The previous month is visible, or the chart is in a confirmed no-data state.

  • Supporting functions are available.

A preflight does not guarantee that every domain will succeed, but it prevents the entire run from starting in an obviously invalid state.


26. Exposing a Minimal Public API

Only the functions needed during normal operation should be exposed.

window.FortiMailToolkit = {
    preflight: preflight,
    run: run,

    stop: function () {
        if (
            typeof window
                .stopFortiMailExport ===
            "function"
        ) {
            window.stopFortiMailExport();
        }
    },

    helpers: {
        isFortiMailDomain:
            isFortiMailDomain,

        getReportingMonth:
            getReportingMonth,

        findVisibleSearchInput:
            findVisibleSearchInput,

        getUniqueSearchText:
            getUniqueSearchText
    }
};

This provides a clean operating interface while retaining selected helpers for troubleshooting.


27. Normal Operating Procedure

After saving the complete script as an Edge DevTools Snippet, the operating sequence is:

Step 1: Open the required report

Navigate to the authorised FortiMail statistics page and open the required 12-month count view.

Step 2: Allow the initial chart to finish loading

The toolkit uses the current page state as its starting point.

Step 3: Run the DevTools Snippet

The toolkit initialises the configuration and exposes the public API.

Step 4: Close the domain dropdown

The normal run expects to control the opening and closing of the selector.

Step 5: Run the preflight

FortiMailToolkit.preflight();

Step 6: Start the export

await FortiMailToolkit.run();

Step 7: Inspect the final state

fortiMailConsolidatedExport

Step 8: Inspect failures if required

console.table(
    fortiMailConsolidatedExport.Failures
);

Step 9: Stop safely if required

FortiMailToolkit.stop();

28. Debugging Techniques That Helped

Inspect the AngularJS scope

For an AngularJS application, the scope associated with a panel can reveal the labels, data, parameters and refresh functions used by the page.

var panel = document.querySelector(
    "#SANITISED_ROOT_SELECTOR .panel"
);

var scope =
    angular.element(panel).scope();

console.log(scope);

This should only be used in an authorised environment. It is also important to avoid modifying unknown properties without understanding how they affect the interface.

Test selectors independently

Before running the full automation:

console.log(
    document.querySelector(
        "#SANITISED_ROOT_SELECTOR"
    )
);

A selector returning null indicates that the interface, route or generated structure may differ.

Inspect current chart labels

var context = getPanelContext();

console.log(
    context.scope.labels[
        context.panelIndex
    ]
);

This confirms the exact date format used by the chart.

Compare before-and-after signatures

var before =
    getChartSignature(context);

// Trigger a known refresh.

var after =
    getChartSignature(context);

console.log({
    Before: before,
    After: after,
    Changed: before !== after
});

Keep Console logging structured

console.log("Searching domain:", {
    Domain: domainName,
    SearchText: searchText
});

Structured logging is easier to inspect than concatenated strings.


29. Reliability Lessons

Do not depend on one click method

Dynamic controls may respond differently to:

  • element.click()

  • A dispatched MouseEvent

  • A click on a child element

  • A click at a screen coordinate

  • A full mouse event sequence

A controlled retry strategy can be more robust than repeating the same failed click.

Do not treat a delay as proof of completion

This is weak:

await sleep(2000);
readChart();

This is stronger:

var state = await waitForChart(
    context,
    previousSignature,
    reportingMonth,
    settings
);

if (!state.NoData) {
    readPreviousMonthTotal(
        context,
        reportingMonth
    );
}

Time still matters, but it should be combined with observable application state.

Do not automatically convert exceptions to zero

This hides data-quality issues:

catch (error) {
    value = 0;
}

This preserves the distinction:

catch (error) {
    value = "";

    failures.push({
        Error: String(
            error.message || error
        )
    });
}

Validate the selected value

Filtering a dropdown is not the same as selecting the correct option.

The final clicked text should exactly equal the requested hostname after normalisation.

Expect implementation details to change

The following are internal dependencies:

  • AngularJS scope properties

  • Generated CSS classes

  • Selector structure

  • Panel array indexes

  • Direction parameter names

  • Chart refresh functions

  • Loading-state properties

They should be isolated into small functions so that future interface changes require localised updates.


30. Security and Operational Boundaries

This implementation is intended for authorised reporting activity inside an already authenticated session.

It does not:

  • Store usernames or passwords

  • Perform login automation

  • Bypass multi-factor authentication

  • Circumvent Citrix controls

  • Elevate permissions

  • Access domains outside the user’s authorised scope

  • Send captured information to an external service

The production source contains environment-specific selectors and reporting domains, but those details should not be published in a public repository or technical article.

A responsible public version should replace them with:

var ROOT = "#YOUR_REPORT_ROOT";

var TARGET_DOMAINS = [
    "domain-one.example.test",
    "domain-two.example.test"
];

Screenshots should also be reviewed for:

  • Hostnames

  • Customer names

  • User identifiers

  • Email addresses

  • Internal URLs

  • Session details

  • Tenant information

  • Operational statistics that are not approved for publication


31. Limitations

This approach has several technical limitations.

It depends on the current UI implementation

If the FortiMail page moves away from AngularJS or changes its component structure, sections of the toolkit may require modification.

It runs in the active browser page

Navigation, refresh, session expiry or manual interference can interrupt the process.

Browser throttling can affect execution

If the tab is placed in the background or the Citrix session becomes inactive, timers and rendering behaviour may change.

It is not an official application API

AngularJS scope inspection and DOM interaction depend on internal implementation details rather than a supported integration contract.

Interface state matters

The correct report and time range must be open before execution.

A blank value requires review

The toolkit deliberately avoids guessing when a valid result cannot be confirmed.


32. Potential Improvements

Several enhancements could make the toolkit easier to operate and maintain.

Automatic retry queue

Failed domain-direction combinations could be retried after the initial run:

async function retryFailures(failures) {
    for (
        var index = 0;
        index < failures.length;
        index++
    ) {
        var failure = failures[index];

        console.log(
            "Retrying:",
            failure.Domain,
            failure.Direction
        );

        // Re-run the relevant combination.
    }
}

A retry limit should be applied to prevent endless loops.

Checkpoint storage

Progress could be written to sessionStorage:

sessionStorage.setItem(
    "fortiMailExportCheckpoint",
    JSON.stringify(
        window.fortiMailConsolidatedExport
    )
);

This would not survive every type of interruption, but it could help recover state during the same browser session.

Failure report download

In addition to the main CSV, the toolkit could produce a separate diagnostic CSV containing:

Domain, Direction, Error

Include run metadata

Useful metadata could include:

  • Reporting month

  • Run start

  • Run completion

  • Successful cell count

  • Failed cell count

  • Toolkit version

Add a dry-run mode

A dry run could verify that every configured hostname can be located without triggering all chart extractions.

Add selector diagnostics

A diagnostic mode could report which selectors and AngularJS properties are currently available after a platform update.


Conclusion

This project began as a repetitive chart-reading task and developed into a state-aware browser automation toolkit.

The most important technical lesson was that reliable UI automation is not primarily about sending clicks. It is about proving that the application has reached the expected state before reading or writing data.

The final design combines:

  • A controlled target list

  • Domain validation

  • Dynamic DOM discovery

  • Framework-aware input events

  • Exact-match selection

  • AngularJS state updates

  • Chart signature comparison

  • Retrieval-state monitoring

  • Previous-month calculation

  • Multi-series aggregation

  • Explicit no-data handling

  • Failure preservation

  • Progress tracking

  • Controlled stopping

  • Browser-generated CSV output

The result is a repeatable monthly extraction workflow that operates inside the existing authenticated interface and produces a structured output suitable for downstream reporting.

The same design principles can be applied to many legacy or restricted enterprise dashboards where an official export does not provide the required consolidated view:

Observe the application state, isolate environment-specific behaviour, validate every transition and never allow an automation failure to silently become business data.

6 views