Turns a document full of company names and email addresses into a batch of personalised emails — and sends them, or writes them to disk for review.
🌍 The interface is available in English (default) and Italian, switchable at runtime from the Configuration tab. 💼 Commercial or redistribution use (including OEM)? See COMMERCIAL-LICENSE.md, or write to marco.lombardo@gmail.com.
All data shown below is fictitious: sample companies on the reserved
.exampledomains, a fake SMTP server and a placeholder password. No real account, no real recipient.
Generated with docs/generate_screenshots.py, which boots the real application under Xvfb with in-memory sample data (no config written, no network calls). Regenerate after a UI change with xvfb-run -a python docs/generate_screenshots.py.
- What Iris is
- Features
- Download
- Installation from source
- Usage
- How it works
- Requirements
- Development
- Testing
- Building a standalone executable
- Troubleshooting
- Scope and limitations
- License & Commercial Licensing
- Contributing
- Disclaimer
Iris is a desktop application that turns a document full of company names and email addresses into a batch of personalised emails — and sends them, or writes them to disk for review.
Point it at a PDF, Excel, CSV, Word or text file; it extracts every (company, address) pair, fills in a template with the company name, and delivers the whole batch over a single SMTP connection. No mail-merge spreadsheet gymnastics, no per-recipient copy-and-paste.
It is not a marketing platform and it talks to no service of its own: the only server it contacts is the SMTP server you configure. Recipient lists, templates and generated messages stay on your machine.
- Multi-format parsing — PDF, Excel (
.xlsx/.xlsm/.xls), CSV, Word (.docx) and plain text (.txt) - Automatic extraction of company names and email addresses, with column order detected by content rather than position
- Personalised templates —
{COMPANY}in the subject and body is replaced per recipient - Template library — save subject, message, Cc/Bcc and attachments under a name and switch between them in one click
- Sender profiles — keep several SMTP configurations (work account, internal relay, a client's server) and swap them from a drop-down
- Cc and Bcc, sent on every message of the batch
- Multiple attachments shared by every message
- SMTP delivery over SSL/TLS, STARTTLS or plain, with or without authentication
- Connection test that validates server and credentials without sending anything
- Single-connection batches with automatic reconnection and early abort on unrecoverable errors
- Configurable pause between messages, to stay under a provider's rate limit
- Offline generation — write
.msgfiles (Outlook on Windows) or standard.emlfiles instead of sending - Bilingual interface — English and Italian, switched at runtime
- Full logging — on screen and to a daily file
- Persistent configuration in
config.ini, with every password stored obfuscated
Everything above is in the free, open-source build. There is no paid tier, no feature gate and no licence key — see License & Commercial Licensing for what the commercial licence is actually for.
Standalone builds for Windows, macOS and Linux are attached to every release:
| Platform | File |
|---|---|
| Windows (x64) | Iris-<version>-windows-x64.zip |
| macOS (Apple silicon) | Iris-<version>-macos-arm64.zip |
| Linux (x64) | Iris-<version>-linux-x64.tar.gz |
Each archive is built on that platform's own runner — PyInstaller does not
cross-compile, so nothing here is emulated or claimed for a platform that was not
actually built. Unpack it, put the program in a folder of your choice and run it:
config.ini and logs/ are created next to it on first use. No installation, and no
Python needed.
The builds are unsigned, so Windows SmartScreen and macOS Gatekeeper warn on first launch.
.msggeneration through Outlook is Windows-only. The macOS and Linux builds write standard.emlfiles instead, which Outlook opens anyway — see Creating emails without sending.
Iris needs Python 3.10 or newer.
git clone https://github.com/MarcoLombardoDev/Iris.git
cd Iris
python -m venv .venv
# Windows: .venv\Scripts\activate
# Linux / macOS: source .venv/bin/activate
pip install -r requirements.txt
python main.pyOn Windows you can use the helper scripts install_dependencies.bat and run.bat.
| Package | Used for | If missing |
|---|---|---|
pypdf |
PDF parsing | PDFs cannot be read (explicit message) |
openpyxl |
.xlsx / .xlsm parsing |
Those formats cannot be read |
xlrd |
.xls parsing (binary format) |
.xls files cannot be read |
python-docx |
.docx parsing |
.docx files cannot be read |
ttkbootstrap |
Modern theme | Falls back to the standard ttk look |
Pillow |
Required by ttkbootstrap to build its theme | Theme falls back to plain ttk |
pywin32 |
.msg generation via Outlook (Windows only) |
.eml files are produced instead |
Every dependency is imported where it is used: a missing library degrades one feature instead of preventing the application from starting.
- Configure the sender — open the Configuration tab, fill in your SMTP server, port, connection type and (optionally) credentials.
- Write the template — a subject and a message, using
{COMPANY}wherever the recipient's name belongs. - Test the connection — press
TEST CONNECTION; nothing is sent, but server and credentials are verified. - Save —
SAVE CONFIGURATIONwritesconfig.ini, reloaded automatically next time. - Load the recipients — go to Processing, press
SELECT THE RECIPIENTS FILEand pick your document. Extraction runs in the background. - Send —
SEND ALL, or select rows and useSEND SELECTED. Successfully sent rows disappear from the list; failures stay, with the reason in the Log tab.
All settings live in the Configuration tab. Fields marked * are required.
| Field | Description |
|---|---|
| SENDER PROFILE | Recall a saved set of sender settings — see Sender Profiles |
| EMAIL ADDRESS * | Address shown as the sender; must be syntactically valid |
| SMTP SERVER * | Outgoing mail server host name |
| SMTP PORT * | Port number (1–65535) |
| CONNECTION TYPE * | SSL/TLS, STARTTLS or None |
| USERNAME | Login for authentication — leave empty if the server does not require it |
| PASSWORD | Password for that login |
Username and password must be both filled in or both empty. Empty means the message is sent without authentication, as internal relays usually expect.
Common provider settings:
| Provider | Server | Port | Connection |
|---|---|---|---|
| Gmail | smtp.gmail.com |
587 | STARTTLS |
| Outlook / Hotmail | smtp-mail.outlook.com |
587 | STARTTLS |
| Microsoft 365 | smtp.office365.com |
587 | STARTTLS |
| Generic implicit TLS | (your host) | 465 | SSL/TLS |
| Internal relay | (your host) | 25 | None |
Gmail and Microsoft 365 require an app password when two-factor authentication is enabled; the account password is rejected.
| Field | Description |
|---|---|
| TEMPLATE | Recall a saved template — see Templates |
| SUBJECT * | Subject line, may contain {COMPANY} |
| CC | Optional, copied on every message of the batch. Comma or semicolon separated for more than one address |
| BCC | Same as CC, but the address never appears in the message itself — only in the SMTP envelope |
| MESSAGE * | Plain-text body, may contain {COMPANY} |
| ATTACHMENTS | Optional files attached to every message. ADD FILES... opens a multi-select dialog; REMOVE drops the highlighted entries |
Subject: Annual notice for {COMPANY}
Message: Dear {COMPANY},
please find attached the annual notice for your company.
Kind regards.
For the recipient "Acme Corporation" the subject becomes Annual notice for Acme Corporation.
{AZIENDA}— the Italian placeholder used by version 1.x — is still accepted, so existing templates keep working.
Iris sends one message per recipient, never one message to the whole batch. Cc and Bcc are the same on every one of those individual messages — a recipient's message is never Cc'd or Bcc'd to another recipient in the same batch, only to whatever fixed address you put in those fields.
| Field | Description |
|---|---|
| PAUSE BETWEEN MESSAGES | Seconds to wait between one message and the next. 0 sends the batch at full speed |
| LANGUAGE | Interface language, applied immediately |
A pause is the simplest cure for a provider that rate limits a sender: Gmail, Microsoft 365 and most shared relays are much happier with one message every second or two than with a hundred in a burst. The value accepts decimals (0.5), and the batch stays interruptible while it waits — pressing to close the window does not sit through the remaining seconds.
Both the sender settings and the message can be saved under a name and recalled later, so switching between "the work account" and "the client's relay", or between an annual notice and a payment reminder, does not mean retyping anything.
| Button | Effect |
|---|---|
| SAVE AS... | Asks for a name and stores the fields currently on screen. An existing name is replaced after confirmation |
| DELETE | Removes the selected entry |
Picking an entry from the drop-down copies it into the fields below, where it can still be edited before sending — loading a profile or a template changes the form, never the file. Saving is immediate: the entry is written to config.ini straight away, and the fields you were editing elsewhere are left untouched.
- Sender profiles hold the address, server, port, connection type and credentials.
- Templates hold the subject, message, Cc, Bcc and the attachment list.
Names are free text; square brackets are dropped and runs of whitespace collapse, because the name becomes a section header inside config.ini.
[PROFILE:Customer Office]
sender_email = notices@example.com
smtp_server = smtp.example.com
smtp_port = 587
connection_type = starttls
smtp_password = b64:...
[TEMPLATE:Annual notice]
email_subject = Annual notice for {COMPANY}
email_body = Dear {COMPANY},...
email_cc = accounting@example.com
attachments = annual_notice.pdf|terms_and_conditions.pdfProfile passwords are obfuscated exactly like the main one — which is to say not encrypted. A
config.iniholding several accounts is that much more sensitive; see Security Notes.
Supported extensions: .pdf, .xlsx, .xlsm, .xls, .csv, .docx, .txt.
| Company Name | |
|---|---|
| Acme Corporation | purchasing@acme.example |
| Globex Ltd | info@globex.example |
- Column order does not matter: the address is found by content, and the company name is the first non-empty cell that is not an address.
- The header row is ignored automatically — it holds no valid address — so it may be present or absent.
- All sheets of the workbook are scanned.
- Rows without a valid address are skipped.
- In CSV files the delimiter (
;,,, tab,|) is detected automatically.
One recipient per line, name and address separated by , ; tab or |:
Acme Corporation, purchasing@acme.example
Globex Ltd; info@globex.example
Initech S.r.l. sales@initech.example
Umbrella Group | contact@umbrella.example
Without a separator, the text preceding the address on the same line becomes the company name:
Acme Corporation purchasing@acme.example
In PDFs, when the address sits alone on its line, the name is looked up in the previous lines (up to three):
Acme Corporation
purchasing@acme.example
Word documents are scanned both as tables (treated like spreadsheet rows) and as paragraphs.
If no name can be determined, the address domain is used: info@acme.example becomes "Company Acme".
- Duplicate addresses are dropped, keeping the first occurrence (case-insensitive).
- Syntactically invalid addresses are ignored.
- Company names are trimmed of surrounding punctuation; a trailing dot is preserved, because it belongs to legal forms such as "S.r.l." or "Inc.".
Before a batch starts, the configuration is validated: sender format, host, numeric port in range, subject and body present, Cc/Bcc addresses well-formed, every attachment existing. Every problem is listed at once.
| Button | Effect |
|---|---|
| SEND SELECTED | Sends to the highlighted rows only (Ctrl / Shift for multiple selection) |
| SEND ALL | Sends to every recipient in the list, after confirmation |
While a batch runs:
- action buttons are disabled, so a batch cannot be started twice;
- the status bar shows progress (
Sending... 12/50); - every successfully sent row is removed from the list, so what remains is exactly what did not go out;
- the whole batch travels over one SMTP connection — considerably faster and less likely to trip provider rate limits;
- if the connection drops, the session reconnects and continues.
On an unrecoverable error (rejected credentials, unknown host, connection refused) the batch is aborted: the remaining recipients are reported as not attempted and stay in the list. Closing the window mid-batch asks for confirmation and stops after the message in flight.
CREATE EMAILS ONLY writes one file per recipient and sends nothing — useful for a review pass, or to forward the messages manually.
- On Windows with Outlook installed,
.msgfiles are produced, ready to open and send. - Otherwise standard
.emlfiles are written, which Outlook, Thunderbird and most clients open natively. Attachments and Cc/Bcc are included in both formats.
Files land in the emails/ folder, named after company and address (for example Acme Corporation_purchasing_acme.example.eml). Before generating, that folder is cleared of previously generated .msg/.eml files — nothing else is touched.
The interface ships in English (default) and Italian. Pick a language from the Configuration tab: the whole window is redrawn immediately, keeping your settings, template and recipient list, and the choice is written to config.ini right away so the next start opens in the same language.
Translations live in a single file, iris/i18n.py, as one dictionary per language. Adding a language means adding a dictionary and registering it in LANGUAGES; the test suite checks that every catalogue defines exactly the same keys with the same placeholders.
The graphical layer and the application logic are separate:
main.py entry point, start-up error handling
iris/ the application package
├── gui.py Tkinter interface (widgets, events, threads)
├── i18n.py translation catalogues and helpers
├── parsers.py recipient extraction from every supported format
├── mailer.py message composition, validation, SMTP sessions
├── msgwriter.py .msg / .eml writing
├── config_store.py config.ini reading and writing
├── paths.py paths for source and frozen builds
└── version.py version number and application naming
gui.py is the only module that imports Tkinter. Everything else is plain logic, so it can be tested without a display, reused from scripts, and debugged with a test rather than by clicking.
Concurrency. Tkinter is not thread-safe, so the project follows one rule: long operations (parsing, sending, file generation, connection test) run on daemon threads, and every UI update is pushed through a queue drained by the main thread every 100 ms. Data read from widgets is collected on the main thread before a worker starts.
Packaging. PIL._tkinter_finder must stay in the PyInstaller hidden imports: without it PIL.ImageTk fails at runtime, ttkbootstrap cannot build its theme, and every themed widget raises — the executable will not start.
| Content | Location |
|---|---|
| Configuration | config.ini, next to the executable or the source tree |
| Daily logs | logs/iris_YYYYMMDD.log |
| Generated emails | emails/ |
config.ini holds the settings currently in use in its [EMAIL] section, plus one [PROFILE:name] or [TEMPLATE:name] section per saved entry. It is looked up in the application folder, then the working directory, then the per-user folder — the first one found wins, so configurations written by earlier versions keep working. If the application folder is not writable (an executable under C:\Program Files, say), everything moves to %APPDATA%\Iris (~/.config/Iris on Linux/macOS). The path actually used is always reported in the Log tab at start-up.
config.iniholds your mail credentials. The password is stored obfuscated (base64,b64:prefix): that stops casual reading, it is not encryption. Treat the file as a secret — do not share it, do not commit it. It is already listed in.gitignore, and on POSIX systems it is created with600permissions.- Nothing is sent without an explicit action. Analysis, template editing and the connection test never deliver a message.
- No telemetry, no external service. The application talks to your SMTP server and to nothing else.
- Recipient lists and generated emails stay on your machine.
- Saved sender profiles multiply the exposure. Each profile keeps its own password, obfuscated the same way, so a
config.iniholding four accounts is four times the problem if it leaks. Do not put aconfig.iniwith saved profiles on a shared drive or in a synced folder.
- Python 3.10 or newer with
tkinter(only needed to run from source) - Windows 10/11, Linux or macOS
.msggeneration requires Windows with Outlook installed; everywhere else the app writes.eml
- Network access to your SMTP server
On Debian/Ubuntu, tkinter ships separately: sudo apt-get install python3-tk.
git clone https://github.com/MarcoLombardoDev/Iris.git
cd Iris
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
python main.pyThe layout and the concurrency rule the code follows are described under How it works. Two conventions worth knowing before the first patch:
- only
iris/gui.pymay import Tkinter — everything else must stay testable without a display; - user-facing strings go through
iris/i18n.py, in both catalogues.
On Windows, install_dependencies.bat, run.bat and test.bat wrap the three commands
above.
pip install -r requirements-dev.txt
python -m pytest tests -vOn Linux the GUI tests need a display:
xvfb-run -a python -m pytest testsWithout a display the GUI tests are skipped automatically and the rest still runs. No test ever sends a real email: delivery is verified against a minimal in-process SMTP server.
| File | Tests | Coverage |
|---|---|---|
tests/test_parsers.py |
39 | Address validation, text, tables, PDF files, CSV, .xls, unsupported formats |
tests/test_mailer.py |
58 | Templates, validation, headers, non-ASCII text, Cc/Bcc, multiple attachments, bulk sending, pause between messages |
tests/test_gui_smoke.py |
40 | Start-up, analysis, validation, configuration, sender profiles, templates, Cc/Bcc, attachments, language switching, end-to-end send |
tests/test_config_store.py |
43 | Save/reload, password obfuscation, encodings, language, saved profiles and templates, Cc/Bcc, multiple attachments, backward compatibility |
tests/test_i18n.py |
19 | Catalogue consistency, placeholders, fallbacks |
tests/test_msgwriter.py |
7 | .eml, attachments, fallback without Outlook, folder cleanup |
tests/test_smtp_integration.py |
5 | Real SMTP dialogue, connection reuse, reconnection, Bcc envelope vs. wire |
tests/test_release_workflow.py |
15 | .github/workflows/release.yml and .github/release-body.md themselves: all three platforms built, every bundle smoke-tested, fixed release title and notes on both publishing paths |
pip install -r requirements-dev.txt
python build.py # or: pyinstaller Iris.spec
python build.py --clean # remove build/ and dist/ onlyThe result is dist/Iris (dist/Iris.exe on Windows) — one file, no console. On Windows
compile.bat runs the test suite before building.
Do not run
pyinstaller --name=Iris main.pyfrom the project folder: it would overwriteIris.specwith a generated file full of absolute paths.build.pyavoids this by writing the temporary spec intobuild/.
The application icon is generated from primitives by
assets/make_icon.py, so the repository carries no third-party
artwork.
PyInstaller does not cross-compile: the binary is native to whatever machine built it.
Without three machines,
.github/workflows/release.yml is the way to get all
three — it builds on windows-latest, macos-latest and ubuntu-latest, smoke-tests
every bundle with --version before publishing it, and attaches one archive per platform
to the release.
- Already built — every tagged version (
vX.Y.Z) is published as a GitHub Release with all three archives attached: no login required, no expiry. - On demand — from the Actions tab, run the Release workflow by hand and give it the tag to build.
| Symptom | Cause / fix |
|---|---|
SMTP AUTH extension not supported |
The server wants no authentication: clear username and password |
| Authentication rejected | Wrong credentials; Gmail and Microsoft 365 need an app password with 2FA enabled |
| Timeout or connection refused | Wrong host/port, or a firewall in the way — use TEST CONNECTION to isolate it |
| No recipients found | The document does not match the recognised layouts; a two-column Excel/CSV file is the safest input |
.xls file not read |
Install xlrd: pip install -r requirements.txt |
.eml produced instead of .msg |
Outlook or pywin32 unavailable; .eml opens in Outlook anyway |
| The window does not start | Run from a terminal to read the error; a missing tkinter is the usual cause on Linux |
The Log tab and logs/iris_YYYYMMDD.log record every operation in detail — attach the day's log file when reporting an issue.
.msggeneration needs Windows and Outlook. Everywhere else Iris writes.eml, which Outlook opens perfectly well; nothing else is lost.- Extraction is heuristic on unstructured input. A two-column Excel or CSV file is read exactly; PDF, Word and plain text are recognised by layout, and an unusual one may yield nothing. The parsed result is always shown for review before anything is sent.
- Passwords in
config.iniare obfuscated, not encrypted — see Security notes. - One SMTP account at a time. Sender profiles switch between accounts; they do not send from several at once.
- No scheduling and no queue. A batch runs while the application is open.
Explicitly out of scope: address harvesting, list buying, open and click tracking, telemetry, and anything else that belongs to a marketing platform rather than to a tool that sends the mail you already had a reason to send.
Iris is open-source software released under the GNU Affero General Public License v3.0 (AGPL-3.0).
Copyright © 2026 Marco Lombardo.
The free build is the whole product. Every feature documented above is in it. There is no paid edition, no feature gate, no licence key, no seat limit and no phone-home. If AGPL-3.0 works for you, you are done reading — Iris is yours to use.
| Use Case | Allowed? | Obligation |
|---|---|---|
| Internal use, any number of machines and users | ✅ Yes | None |
| Modify it and keep the changes to yourself | ✅ Yes | None |
| Fork and publish on GitHub | ✅ Yes | Must stay AGPL-3.0 |
| Redistribute it, modified or not, under AGPL-3.0 | ✅ Yes | Must ship the source |
| Deploy a modified version as a network service | ✅ Yes | Must publish the source of your modified version |
| Integrate into a closed-source product used internally | Requires a Commercial licence | |
| Offer as a proprietary SaaS without sharing source | ❌ Not under AGPL | Requires a Redistribution licence |
| Embed it in, or ship it inside, a product you sell to third parties | ❌ Not under AGPL | Requires a Redistribution licence |
The dividing line is one rule: AGPL-3.0 is free as long as the source stays open.
The commercial offer removes the copyleft obligation, and nothing else. It splits into two branches that answer different questions — Commercial, sized by how big the organisation using Iris internally is, and Redistribution, needed whenever the software (or a derivative) reaches third parties, regardless of size:
Community AGPL-3.0, free
Commercial Small (1–49 employees) · Medium (50–249) · Large (250–999) · Enterprise (1,000+ / group)
Redistribution Standard · Enterprise
| Tier | Price | Perpetual | Scope |
|---|---|---|---|
| Community | Free | — | Everything Iris does, under AGPL-3.0. Unlimited internal use. |
| Commercial — Small | €1,500 / year | €4,500 | 1–49 employees, internal use, one legal entity. |
| Commercial — Medium | €3,000 / year | €9,000 | 50–249 employees, internal use, one legal entity. |
| Commercial — Large | €5,500 / year | €16,500 | 250–999 employees, internal use, one legal entity. |
| Commercial — Enterprise | from €9,000 / year | — | 1,000+ employees, or a Corporate Group scope. |
| Redistribution — Standard | €4,000 / year | €12,000 | Embed it in a product you sell, or ship it to customers. |
| Redistribution — Enterprise | from €15,000 / year | — | Large-scale distribution — worldwide, high volume, or OEM. |
A perpetual licence is three times the annual rate of the same tier, bought once, covering the major version current at purchase. Both Enterprise tiers are negotiated per case instead.
The same commitments apply at every paid tier:
- Email support is always included — 5 business days at Commercial Small down to 2 at either Enterprise tier. It is never sold separately to a paying customer.
- Custom development is never included, at any tier. It is available on request and quoted separately, per project, at a fixed price agreed before work starts (indicative day rate: €600 / day).
- No retroactive price rise, cancel any time. Versions released during your term stay licensed to you.
- 50% off for organisations under 10 employees and €1M revenue. Free commercial licences for non-profits, academia and published research — ask.
A Commercial licence, below Enterprise, covers exactly one legal entity: it does not automatically extend to other companies in the same group, and it does not include redistribution, OEM or embedding rights — those need a Redistribution licence on top. Prices are per licensed legal entity, excluding VAT. Seats are never counted. Full terms, the Employee Count and Corporate Group definitions, and the third-party component review: COMMERCIAL-LICENSE.md.
⚠️ Every source dependency is permissively licensed — the PDF reader was moved from PyMuPDF (AGPL / Artifex commercial) topypdf(BSD-3-Clause) precisely so this stays true. A downloadable build is a different question: it is a frozen bundle of 109 native libraries, most of which norequirements.txtmentions. All of them are inventoried, with the evidence for each licence, in THIRD-PARTY-LICENSES.md, and the licence texts ship inside the archive. What that means for a redistributor is §11.
Everything commercial — buying a licence, asking for a quote, commissioning custom development, or checking whether you need a licence at all (the answer is often no) — goes to one address:
marco.lombardo@gmail.com — Marco Lombardo
Please keep GitHub Issues for bugs and feature requests, not for licensing.
Contributions are welcome. All contributors must agree to the Contributor License Agreement (CLA) before a Pull Request can be merged. The CLA grants the Project Owner the right to dual-license contributions under AGPL-3.0 and commercial terms — this is what makes the dual-licensing model sustainable.
To agree to the CLA: include
I have read and agree to the Contributor License Agreement (CLA.md).in your Pull Request description.
Practical expectations:
- Only
iris/gui.pymay import Tkinter; new logic goes in the other modules, with tests. - Every bug fix arrives with a test that fails without the fix.
- User-facing strings go through
iris/i18n.py, in both catalogues. - Bump the version only in
iris/version.py, and add aCHANGELOG.mdentry. - Never commit
config.ini, real credentials, or real recipient lists.
CONTRIBUTING.md covers the process in full.
Iris sends real emails to real recipients as soon as you press SEND ALL.
Verify your recipient list, your template and your SMTP settings before starting a
batch — use CREATE EMAILS ONLY for a dry run and TEST CONNECTION to validate the
server.
You remain responsible for the content you send and for complying with the applicable rules on electronic communications and personal data — including, in the EU, the GDPR and the consent requirements for unsolicited commercial email. The authors and contributors accept no liability for messages sent with this software, for delivery failures, or for any consequence of its use.
The software is provided "as is", without warranty of any kind, as set out in sections 15 and 16 of the AGPL-3.0.
Copyright © 2026 Marco Lombardo. Licensed under AGPL-3.0 — commercial licensing available.



