Converted Go MIAB tool to Python.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vscode/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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."""
|
||||||
@@ -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."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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*"]
|
||||||
Reference in New Issue
Block a user