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
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:
Open the domain selector.
Search for the domain.
Select the matching entry.
Wait for the chart to update.
Select the Incoming direction.
Capture the previous month’s value.
Repeat for Outgoing.
Repeat for Unknown.
Move to the next domain.
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:
Internal functions and variables do not unnecessarily pollute the global browser scope.
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 |
| 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:
A dispatched
MouseEventA 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.


