From 779e820a384db53a445e6d6b52f0662c2cc7c9d5 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 6 Sep 2026 16:54:05 +0200 Subject: [PATCH] Converted Go MIAB tool to Python. --- .gitignore | 5 ++ LICENSE | 21 +++++ README.md | 131 ++++++++++++++++++++++++++++ mailinabox/__init__.py | 5 ++ mailinabox/cli/__init__.py | 5 ++ mailinabox/cli/miab.py | 109 ++++++++++++++++++++++++ mailinabox/dns.py | 170 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 19 +++++ 8 files changed, 465 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 mailinabox/__init__.py create mode 100644 mailinabox/cli/__init__.py create mode 100644 mailinabox/cli/miab.py create mode 100644 mailinabox/dns.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb6eb67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.egg-info/ +dist/ +build/ +.vscode/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b37d7bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sławomir Koszewski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..45e1dce --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# mailinabox + +Python client library and CLI for the [Mail-in-a-Box](https://mailinabox.email/) admin DNS API. + +Licensed under the [MIT License](LICENSE). + +## Installation + +The `miab` command is installed into the active virtual environment by any of the methods below. +The package depends on `cloud-tools`, which provides the `python_helpers` package. + +### Local install + +```sh +pip install -e . # editable, for development: picks up source changes without reinstalling +pip install . # normal install: copies the package in +``` + +### Install from git + +```sh +pip install git+@main +``` + +### Running without installing + +From a repo checkout, run the command as a module with `-m` instead of installing the package: + +```sh +python3 -m mailinabox.cli.miab --help +``` + +From outside the checkout, point `PYTHONPATH` at it: + +```sh +PYTHONPATH=/path/to/mailinabox python3 -m mailinabox.cli.miab --help +``` + +## Library + +```python +from mailinabox.dns import Client + +# Explicit credentials: +client = Client("box.example.com", "admin@example.com", "password") + +# From environment variables (MIAB_* or MAILINABOX_* style): +client = Client() + +client.set_record("foo.example.com", "TXT", "v=spf1 ~all") +client.add_record("foo.example.com", "A", "1.2.3.4") +client.delete_record("foo.example.com", "A", "1.2.3.4") + +records = client.list_records("TXT") +``` + +`list_records` returns a list of `DNSRecord` named tuples with `name`, `type` and `value` +fields. Every method raises `mailinabox.dns.MailInABoxError` if credentials are missing or the +API rejects the request. + +## CLI + +```sh +miab [--env-file FILE] list [--type TYPE] +miab [--env-file FILE] set [--type TYPE] +miab [--env-file FILE] add [--type TYPE] +miab [--env-file FILE] delete [--type TYPE] [value] +``` + +`--type` defaults to `A` for `set`, `add` and `delete`; `list` shows every record type unless +one is given. Omitting the value for `delete` removes all records of that type for the name. + +```sh +miab list --type TXT +miab set --type TXT foo.example.com "v=spf1 ~all" +miab add --type A foo.example.com 1.2.3.4 +miab delete --type A foo.example.com 1.2.3.4 +``` + +## Credentials + +Credentials are read from the environment; either naming style is accepted, with `MIAB_*` +taking precedence. + +```sh +# MIAB style +export MIAB_HOST=box.example.com +export MIAB_USERNAME=admin@example.com +export MIAB_PASSWORD=password + +# Mail-in-a-Box style +export MAILINABOX_BASE_URL=https://box.example.com +export MAILINABOX_EMAIL=admin@example.com +export MAILINABOX_PASSWORD=password +``` + +The hostname is parsed out of `MAILINABOX_BASE_URL` when `MIAB_HOST` is not set. + +### Environment file + +`--env-file|-E` reads the same variables from a docker-style file, overriding any inherited +from the shell. It is a global option and goes before the command name: + +```sh +miab --env-file box.env list --type TXT +``` + +``` +# Mail-in-a-Box credentials +MIAB_HOST=box.example.com +MIAB_USERNAME=admin@example.com +MIAB_PASSWORD=password +``` + +Blank lines and lines whose first non-blank character is `#` are ignored; every other line must +be `NAME=VALUE`. Values are taken literally, as `docker run --env-file` does: quotes are kept +rather than stripped, `#` after the `=` is part of the value, and no variable interpolation is +performed. + +## Tests + +```sh +python3 -m unittest discover -s tests +``` + +## AI Disclaimer + +This project was generated with the assistance of AI tools. While efforts have been made to +ensure the accuracy and reliability of the code, users should review and test the code +thoroughly before using it in production environments. The author is not responsible for any +issues arising from the use of this code. diff --git a/mailinabox/__init__.py b/mailinabox/__init__.py new file mode 100644 index 0000000..79210c8 --- /dev/null +++ b/mailinabox/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2026 Slawomir Koszewski. All rights reserved. +# Use of this source code is governed by the MIT License +# that can be found in the LICENSE file. + +"""mailinabox - client library and CLI for the Mail-in-a-Box admin DNS API.""" diff --git a/mailinabox/cli/__init__.py b/mailinabox/cli/__init__.py new file mode 100644 index 0000000..cb02f89 --- /dev/null +++ b/mailinabox/cli/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2026 Slawomir Koszewski. All rights reserved. +# Use of this source code is governed by the MIT License +# that can be found in the LICENSE file. + +"""Command-line entry points for the mailinabox package.""" diff --git a/mailinabox/cli/miab.py b/mailinabox/cli/miab.py new file mode 100644 index 0000000..245ef0a --- /dev/null +++ b/mailinabox/cli/miab.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 Slawomir Koszewski. All rights reserved. +# Use of this source code is governed by the MIT License +# that can be found in the LICENSE file. + +"""Manage custom DNS records on a Mail-in-a-Box server.""" + +import argparse + +from mailinabox.dns import Client, MailInABoxError +from python_helpers.console import fail +from python_helpers.env import load_env_file + +EXAMPLES = """ +Examples: + %(prog)s list + %(prog)s list --type TXT + %(prog)s --env-file box.env list + %(prog)s set --type TXT foo.example.com "v=spf1 ~all" + %(prog)s add --type A foo.example.com 1.2.3.4 + %(prog)s delete --type A foo.example.com 1.2.3.4 + %(prog)s delete --type TXT foo.example.com + +Credentials are read from the environment; either naming style is accepted: + MIAB_HOST / MAILINABOX_BASE_URL Mail-in-a-Box hostname (MIAB_HOST takes precedence; + the hostname is parsed out of MAILINABOX_BASE_URL) + MIAB_USERNAME / MAILINABOX_EMAIL Admin email address + MIAB_PASSWORD / MAILINABOX_PASSWORD Admin password + +--env-file reads them from a docker-style file instead, overriding the inherited +environment: + MIAB_HOST=box.example.com + # comments and blank lines are ignored + MIAB_USERNAME=admin@example.com + MIAB_PASSWORD=password +""" + + +def build_parser(): + """Build the argparse parser for this script.""" + parser = argparse.ArgumentParser( + description="Manage custom DNS records on a Mail-in-a-Box server.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=EXAMPLES, + ) + parser.add_argument( + "--env-file", "-E", + help="Read credentials from a docker-style NAME=VALUE file, overriding the environment", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="List custom DNS records") + list_parser.add_argument( + "--type", "-t", + default="", + help="Filter by record type (e.g. A, TXT, MX); lists every type if omitted", + ) + + set_parser = subparsers.add_parser("set", help="Set a DNS record, replacing existing ones") + set_parser.add_argument("--type", "-t", default="A", help="Record type (e.g. A, TXT) (default: A)") + set_parser.add_argument("name", help="Fully qualified record name") + set_parser.add_argument("value", help="Record value") + + add_parser = subparsers.add_parser("add", help="Add a DNS record, keeping existing ones") + add_parser.add_argument("--type", "-t", default="A", help="Record type (e.g. A, TXT) (default: A)") + add_parser.add_argument("name", help="Fully qualified record name") + add_parser.add_argument("value", help="Record value") + + delete_parser = subparsers.add_parser("delete", help="Delete a DNS record") + delete_parser.add_argument("--type", "-t", default="A", help="Record type (e.g. A, TXT) (default: A)") + delete_parser.add_argument("name", help="Fully qualified record name") + delete_parser.add_argument( + "value", + nargs="?", + default="", + help="Record value; deletes every record of that type for the name if omitted", + ) + + return parser + + +def main(): + """Parse arguments and run the requested DNS command.""" + args = build_parser().parse_args() + + if args.env_file: + load_env_file(args.env_file) + + try: + client = Client() + if args.command == "list": + records = client.list_records(args.type) + print(f"Custom {args.type} records:" if args.type else "Custom DNS records:") + for record in records: + print(f" - {record.name} ({record.type}): {record.value}") + else: + record_commands = { + "set": client.set_record, + "add": client.add_record, + "delete": client.delete_record, + } + record_commands[args.command](args.name, args.type, args.value) + except MailInABoxError as error: + fail(error) + + +if __name__ == "__main__": + main() diff --git a/mailinabox/dns.py b/mailinabox/dns.py new file mode 100644 index 0000000..e9ba4ad --- /dev/null +++ b/mailinabox/dns.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026 Slawomir Koszewski. All rights reserved. +# Use of this source code is governed by the MIT License +# that can be found in the LICENSE file. + +"""Client for the Mail-in-a-Box admin custom DNS API.""" + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from collections import namedtuple + +TIMEOUT = 30 + +DNSRecord = namedtuple("DNSRecord", ["name", "type", "value"]) + + +class MailInABoxError(Exception): + """Raised when the Mail-in-a-Box API cannot be reached or rejects a request.""" + + +class Client: + """Connection configuration for the Mail-in-a-Box admin DNS API. + + Any argument left as None is filled from the environment: + + host: MIAB_HOST, or the hostname of MAILINABOX_BASE_URL + username: MIAB_USERNAME or MAILINABOX_EMAIL + password: MIAB_PASSWORD or MAILINABOX_PASSWORD + + Args: + host: hostname of the Mail-in-a-Box server. + username: admin email address to authenticate as. + password: admin password. + + Raises: + MailInABoxError: if a value is still missing after the environment lookup. + """ + + def __init__(self, host=None, username=None, password=None): + self.host = host or os.environ.get("MIAB_HOST") or _base_url_hostname() + self.username = username or os.environ.get("MIAB_USERNAME") or os.environ.get("MAILINABOX_EMAIL") + self.password = password or os.environ.get("MIAB_PASSWORD") or os.environ.get("MAILINABOX_PASSWORD") + + missing = [ + name + for value, name in ( + (self.host, "MIAB_HOST or MAILINABOX_BASE_URL"), + (self.username, "MIAB_USERNAME or MAILINABOX_EMAIL"), + (self.password, "MIAB_PASSWORD or MAILINABOX_PASSWORD"), + ) + if not value + ] + if missing: + raise MailInABoxError(f"missing required environment variables: {', '.join(missing)}") + + def set_record(self, name, record_type, value): + """Replace all existing records of the given type for name with value. + + Args: + name: fully qualified record name. + record_type: DNS record type, for example A or TXT. + value: record value to store. + """ + self._request("PUT", self._record_url(name, record_type), value) + + def add_record(self, name, record_type, value): + """Add a record without replacing existing ones. + + Args: + name: fully qualified record name. + record_type: DNS record type, for example A or TXT. + value: record value to add. + """ + self._request("POST", self._record_url(name, record_type), value) + + def delete_record(self, name, record_type, value=""): + """Remove the record matching name, record_type and value. + + Args: + name: fully qualified record name. + record_type: DNS record type, for example A or TXT. + value: record value to remove; an empty value removes all records of that type. + """ + self._request("DELETE", self._record_url(name, record_type), value) + + def list_records(self, record_type=""): + """List the custom DNS records configured on the box. + + Args: + record_type: if given, only records of this type are returned. + + Returns: + A list of DNSRecord tuples. + + Raises: + MailInABoxError: if the response is not valid JSON. + """ + body = self._request("GET", f"https://{self.host}/admin/dns/custom", "") + try: + raw = json.loads(body) + except ValueError as error: + raise MailInABoxError(f"failed to parse response: {error}") from error + + wanted = record_type.upper() + return [ + DNSRecord(record["qname"], record["rtype"], record["value"]) + for record in raw + if not record_type or record["rtype"] == wanted + ] + + def _record_url(self, name, record_type): + """Build the API URL for a single record, omitting the type segment for A records. + + Args: + name: fully qualified record name. + record_type: DNS record type, for example A or TXT. + + Returns: + The record's API URL. + """ + if record_type.upper() == "A": + return f"https://{self.host}/admin/dns/custom/{name}" + return f"https://{self.host}/admin/dns/custom/{name}/{record_type.upper()}" + + def _request(self, method, url, body): + """Perform an authenticated API request and return the response body. + + Args: + method: HTTP method to use. + url: full request URL. + body: request body; an empty body is sent as no body at all. + + Returns: + The response body as a string. + + Raises: + MailInABoxError: if the request fails or the status is not 200. + """ + credentials = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii") + headers = {"Authorization": f"Basic {credentials}"} + if body: + headers["Content-Type"] = "text/plain" + + request = urllib.request.Request( + url, + data=body.encode("utf-8") if body else None, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen(request, timeout=TIMEOUT) as response: + payload = response.read().decode("utf-8") + if response.status != 200: + raise MailInABoxError(f"unexpected status {response.status}: {payload.strip()}") + return payload + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace").strip() + raise MailInABoxError(f"unexpected status {error.code}: {detail}") from error + except urllib.error.URLError as error: + raise MailInABoxError(f"request failed: {error.reason}") from error + + +def _base_url_hostname(): + """Return the hostname part of MAILINABOX_BASE_URL, or None if it is unset.""" + base_url = os.environ.get("MAILINABOX_BASE_URL") + return urllib.parse.urlparse(base_url).hostname if base_url else None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f8c8e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "mailinabox" +version = "1.0.0" +description = "Python client library and CLI for the Mail-in-a-Box admin DNS API" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.9" +dependencies = ["cloud-tools"] + +[project.scripts] +miab = "mailinabox.cli.miab:main" + +[tool.setuptools.packages.find] +include = ["mailinabox*"]