Converted Go MIAB tool to Python.

This commit is contained in:
2026-09-06 16:54:05 +02:00
commit 779e820a38
8 changed files with 465 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.egg-info/
dist/
build/
.vscode/
+21
View File
@@ -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.
+131
View File
@@ -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+<repository-url>@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] <name> <value>
miab [--env-file FILE] add [--type TYPE] <name> <value>
miab [--env-file FILE] delete [--type TYPE] <name> [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.
+5
View File
@@ -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."""
+5
View File
@@ -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."""
+109
View File
@@ -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()
+170
View File
@@ -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
+19
View File
@@ -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*"]