feat: initial ispconfig-py SDK (sites / dns / mail / databases / clients)
Python 3.10+ SDK wrapping the ISPConfig remote SOAP API so we stop writing
throwaway PHP snippets every time we need to touch a site, zone, or mailbox.
Why no zeep: ISPConfig's /remote/index.php exposes PHP SoapServer in non-WSDL
mode and refuses WSDL generation (?wsdl returns a fault). zeep requires WSDL,
so the stated dependency wouldn't actually work. Instead we hand-roll SOAP
envelopes with the stdlib (urllib + xml.etree). Zero runtime deps.
Structure:
- src/ispconfig/_soap.py — envelope encode/decode, fault surfacing
- src/ispconfig/client.py — ISPConfigClient context manager, retry
- src/ispconfig/exceptions.py — ISPConfigError / Auth / Permission / NotFound / Fault
- src/ispconfig/sites.py — web_domain get/add/update/delete + enable_php / enable_letsencrypt helpers
- src/ispconfig/dns.py — zones + A/CNAME/MX/TXT records, dns_a_add type-column workaround
- src/ispconfig/mail.py — mail domains, users, forwards, create_mailbox helper
- src/ispconfig/databases.py — convenience facade over sites_database_*
- src/ispconfig/clients.py — client + sys_groupid lookups
- src/ispconfig/types.py — TypedDicts for response shapes (no pydantic)
Institutional knowledge baked into docstrings + README footgun list:
- sites_web_domain_update 2nd arg is client_id (not primary_id); admin = 0
- sys_groupid=1 -> pass client_id=0 on update, else ownership churns
- fastcgi_php_version vs server_php_id depending on panel version
- dns_a_add type-column bug (<= 3.2.11) — wrapper issues follow-up update
- dns_zone_get_id wants origin WITHOUT trailing dot on 3.2.11+ (contrary to
what the older snippets say). Verified live against Rackham 2026-04-22.
- mail_user_get returns a bare map on exactly-one-match filter dicts —
wrapper normalizes to list
- session timeouts mid-op: client detects + re-auths once (max_retries knob)
Tests:
- tests/test_unit.py — 12 unit tests against a fake transport
- tests/test_smoke.py — live read-only smoke test, gated on env vars:
ISPCONFIG_TEST_URL, ISPCONFIG_TEST_USER, ISPCONFIG_TEST_PASS
Covers login, web_domain_get(156), dns_zone_get_id, mail_user_get filter.
Tooling:
- mypy strict-ish (disallow untyped defs, warn-return-any, no implicit optional)
- ruff with E/F/W/I/B/UP/N/SLF/RUF lint sets
- pip install -e .[dev] for pytest / mypy / ruff
This commit is contained in:
commit
9438b4e751
19 changed files with 1797 additions and 0 deletions
152
src/ispconfig/mail.py
Normal file
152
src/ispconfig/mail.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""``mail.*`` — mail domains, mailboxes, forwarders.
|
||||
|
||||
Note: the ISPConfig remote method is ``mail_forward_*`` (singular), not
|
||||
``mail_forwarding_*``. Our wrapper exposes :meth:`MailModule.forward_add`
|
||||
etc; the old ``forwarding`` names are available as aliases for anyone coming
|
||||
from the PHP snippets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from .types import MailDomain, MailUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import ISPConfigClient
|
||||
|
||||
log = logging.getLogger("ispconfig.mail")
|
||||
|
||||
|
||||
class MailModule:
|
||||
def __init__(self, client: ISPConfigClient) -> None:
|
||||
self._c = client
|
||||
|
||||
# ---- mail domains -------------------------------------------------
|
||||
|
||||
def domain_get(self, primary_id: int) -> MailDomain:
|
||||
return cast(MailDomain, self._c._call("mail_domain_get", ("primary_id", int(primary_id))))
|
||||
|
||||
def domain_get_by_domain(self, domain: str) -> list[MailDomain]:
|
||||
return self._c._call("mail_domain_get_by_domain", ("domain", domain)) or []
|
||||
|
||||
def domain_add(self, client_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_domain_add",
|
||||
("client_id", int(client_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def domain_update(self, client_id: int, primary_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_domain_update",
|
||||
("client_id", int(client_id)),
|
||||
("primary_id", int(primary_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def domain_delete(self, primary_id: int) -> int:
|
||||
return int(self._c._call("mail_domain_delete", ("primary_id", int(primary_id))))
|
||||
|
||||
# ---- mail users ---------------------------------------------------
|
||||
|
||||
def user_get(self, primary_id: int | Mapping[str, Any]) -> MailUser | list[MailUser]:
|
||||
"""Fetch a mailuser.
|
||||
|
||||
Pass an int ``mailuser_id`` to get a single row, or a dict of filter
|
||||
key/values (e.g. ``{'email': 'x@y.com'}``) to get a list.
|
||||
|
||||
.. note::
|
||||
ISPConfig's SOAP is inconsistent: a filter dict that matches
|
||||
multiple rows returns an array, but a filter dict that matches
|
||||
*exactly one* row sometimes returns a single map instead of a
|
||||
1-element array. When a filter dict is passed we always normalize
|
||||
to a list so callers can iterate without surprises.
|
||||
"""
|
||||
if isinstance(primary_id, Mapping):
|
||||
result = self._c._call("mail_user_get", ("primary_id", dict(primary_id)))
|
||||
if result is None:
|
||||
return cast(list[MailUser], [])
|
||||
if isinstance(result, list):
|
||||
return cast(list[MailUser], result)
|
||||
# single-hit quirk — wrap into a list for consistency.
|
||||
return cast(list[MailUser], [result])
|
||||
return cast(MailUser, self._c._call("mail_user_get", ("primary_id", int(primary_id))))
|
||||
|
||||
def user_add(self, client_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_user_add",
|
||||
("client_id", int(client_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def user_update(self, client_id: int, primary_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_user_update",
|
||||
("client_id", int(client_id)),
|
||||
("primary_id", int(primary_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def user_delete(self, primary_id: int) -> int:
|
||||
return int(self._c._call("mail_user_delete", ("primary_id", int(primary_id))))
|
||||
|
||||
# ---- mail forward -------------------------------------------------
|
||||
|
||||
def forward_add(self, client_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_forward_add",
|
||||
("client_id", int(client_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def forward_update(self, client_id: int, primary_id: int, params: Mapping[str, Any]) -> int:
|
||||
return int(self._c._call(
|
||||
"mail_forward_update",
|
||||
("client_id", int(client_id)),
|
||||
("primary_id", int(primary_id)),
|
||||
("params", dict(params)),
|
||||
))
|
||||
|
||||
def forward_delete(self, primary_id: int) -> int:
|
||||
return int(self._c._call("mail_forward_delete", ("primary_id", int(primary_id))))
|
||||
|
||||
# Aliases for callers coming from the old ``mail_forwarding_*`` PHP names.
|
||||
forwarding_add = forward_add
|
||||
forwarding_update = forward_update
|
||||
forwarding_delete = forward_delete
|
||||
|
||||
# ---- helpers ------------------------------------------------------
|
||||
|
||||
def create_mailbox(
|
||||
self,
|
||||
client_id: int,
|
||||
domain: str,
|
||||
local_part: str,
|
||||
password: str,
|
||||
*,
|
||||
server_id: int = 1,
|
||||
quota_mb: int = 1024,
|
||||
name: str | None = None,
|
||||
) -> int:
|
||||
"""Shorthand for ``user_add`` with typical defaults.
|
||||
|
||||
Returns the new ``mailuser_id``.
|
||||
"""
|
||||
email = f"{local_part}@{domain}"
|
||||
params: dict[str, Any] = {
|
||||
"server_id": int(server_id),
|
||||
"email": email,
|
||||
"login": email,
|
||||
"password": password,
|
||||
"name": name or local_part,
|
||||
"quota": int(quota_mb) * 1024 * 1024, # ISPConfig stores bytes
|
||||
"postfix": "y",
|
||||
"access": "y",
|
||||
"disableimap": "n",
|
||||
"disablepop3": "n",
|
||||
"disablesmtp": "n",
|
||||
}
|
||||
return self.user_add(client_id, params)
|
||||
Loading…
Add table
Add a link
Reference in a new issue