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