diff --git a/cli/__init__.py b/cli/__init__.py index 43bdc91..ddfad23 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -498,4 +498,4 @@ def parser_stub(parser): parser.set_defaults(_handle=lambda *args: parser.print_usage()) -from . import chat, config, dbconf, dbtools, domain, exmdb, fetchmail, fs, ldap, mconf, misc, mlist, org, remote, server, services, user +from . import chat, config, dbconf, dbtools, domain, exmdb, fetchmail, fs, ldap, mconf, misc, mlist, org, remote, server, services, user, domain_smtp_gateway diff --git a/cli/domain.py b/cli/domain.py index feb50cf..56e2e57 100644 --- a/cli/domain.py +++ b/cli/domain.py @@ -265,6 +265,9 @@ def addProperties(parser, init): show.add_argument("domainspec", help="Domain ID or name").completer = _cliDomainDomainspecAutocomp show.add_argument("-f", "--filter", nargs="*", help="Filter by attribute, e.g. -f ID=42") show.add_argument("-s", "--sort", nargs="*", help="Sort by attribute, e.g. -s domainname,desc") + # Per-domain SMTP gateway (grommunio-admin API integration). + from . import domain_smtp_gateway + domain_smtp_gateway._register(sub) @Cli.command("domain", _setupCliDomain, help="Domain management") diff --git a/cli/domain_smtp_gateway.py b/cli/domain_smtp_gateway.py new file mode 100644 index 0000000..69d3d0c --- /dev/null +++ b/cli/domain_smtp_gateway.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 grommunio GmbH +# +# grommunio-admin domain smtp-gateway … +# +# set --host HOST [--port 587] [--encryption starttls] +# [--username USER] [--password PASS] +# [--disable] [--description "…"] +# Create or update the per-domain SMTP gateway config. +# +# show +# Print the current configuration (password is masked). +# +# delete +# Remove the per-domain gateway config; the MTA then falls back +# to its default routing (relayhost/direct delivery). +# +# list +# List all configured gateways. + +from . import Cli, InvalidUseError +from .common import domainCandidates +from argparse import ArgumentParser + +_parsers = [] + + +def _register(sub): + """Register the smtp-gateway subcommand.""" + p = sub.add_parser("smtp-gateway", help="Manage per-domain SMTP gateway") + sp = p.add_subparsers(dest="_action", metavar="action", required=True) + + p_set = sp.add_parser("set", help="Create or update SMTP gateway for a domain") + p_set.add_argument("domainspec", help="Domain name or ID") + p_set.add_argument("--host", required=True, help="SMTP server hostname or IP") + p_set.add_argument("--port", type=int, default=25, + help="SMTP server port (default 25)") + p_set.add_argument("--encryption", default="none", + choices=("none", "starttls", "starttls_unverified", "tls"), + help="Encryption mode (default none)") + p_set.add_argument("--username", help="SMTP authentication username") + p_set.add_argument("--password", help="SMTP authentication password") + p_set.add_argument("--disable", action="store_true", + help="Store the config but mark it as disabled") + p_set.add_argument("--description", help="Free-form description") + p_set.set_defaults(_handle=cliDomainSmtpGateway) + _parsers.append(p_set) + + p_show = sp.add_parser("show", help="Show SMTP gateway config for a domain") + p_show.add_argument("domainspec", help="Domain name or ID") + p_show.set_defaults(_handle=cliDomainSmtpGateway) + _parsers.append(p_show) + + p_del = sp.add_parser("delete", help="Remove SMTP gateway config for a domain") + p_del.add_argument("domainspec", help="Domain name or ID") + p_del.set_defaults(_handle=cliDomainSmtpGateway) + _parsers.append(p_del) + + p_list = sp.add_parser("list", help="List all configured SMTP gateways") + p_list.set_defaults(_handle=cliDomainSmtpGateway) + _parsers.append(p_list) + return p + + +def cliDomainSmtpGateway(args): + cli = args._cli + cli.require("DB") + action = args._action + if action == "set": + return _do_set(cli, args) + if action == "show": + return _do_show(cli, args) + if action == "delete": + return _do_delete(cli, args) + if action == "list": + return _do_list(cli, args) + raise InvalidUseError("Unknown smtp-gateway action: {}".format(action)) + + +def _do_set(cli, args): + from orm.domains import Domains + from orm.domain_smtp_gateway import DomainSmtpGateway + from orm import DB + + domains = domainCandidates(args.domainspec).all() + if len(domains) != 1: + cli.print(cli.col("Domain not found or ambiguous: {}".format(args.domainspec), "red")) + return 1 + domain = domains[0] + + gw = DomainSmtpGateway.query.filter(DomainSmtpGateway.domainID == domain.ID).first() + if gw is None: + gw = DomainSmtpGateway({"domainID": domain.ID, "host": args.host}) + else: + gw.host = args.host + gw.port = args.port + gw.encryption = args.encryption + if args.username is not None: + gw.username = args.username + if args.password is not None: + gw.password = args.password + gw.enabled = 0 if args.disable else 1 + if args.description is not None: + gw.description = args.description + + if gw not in DB.session: + DB.session.add(gw) + DB.session.commit() + + cli.print(cli.col("SMTP gateway for {} saved.".format(domain.domainname), "green")) + return 0 + + +def _do_show(cli, args): + from orm.domains import Domains + from orm.domain_smtp_gateway import DomainSmtpGateway + domains = domainCandidates(args.domainspec).all() + if len(domains) != 1: + cli.print(cli.col("Domain not found or ambiguous", "red")) + return 1 + domain = domains[0] + gw = DomainSmtpGateway.query.filter(DomainSmtpGateway.domainID == domain.ID).first() + if gw is None: + cli.print(cli.col("No SMTP gateway configured for {}".format(domain.domainname), "yellow")) + return 0 + cli.print(cli.col("SMTP gateway for {}:".format(domain.domainname), attrs=["bold"])) + cli.print(" host: {}".format(gw.host)) + cli.print(" port: {}".format(gw.port)) + cli.print(" encryption: {}".format(gw.encryption)) + cli.print(" username: {}".format(gw.username or "")) + cli.print(" password: {}".format("***" if gw.password else "(not set)")) + cli.print(" enabled: {}".format(bool(gw.enabled))) + cli.print(" description: {}".format(gw.description or "")) + return 0 + + +def _do_delete(cli, args): + from orm.domains import Domains + from orm.domain_smtp_gateway import DomainSmtpGateway + from orm import DB + domains = domainCandidates(args.domainspec).all() + if len(domains) != 1: + cli.print(cli.col("Domain not found or ambiguous", "red")) + return 1 + domain = domains[0] + gw = DomainSmtpGateway.query.filter(DomainSmtpGateway.domainID == domain.ID).first() + if gw is None: + cli.print(cli.col("No SMTP gateway configured for {}".format(domain.domainname), "yellow")) + return 0 + DB.session.delete(gw) + DB.session.commit() + cli.print(cli.col("SMTP gateway for {} removed.".format(domain.domainname), "green")) + return 0 + + +def _do_list(cli, args): + from orm.domain_smtp_gateway import DomainSmtpGateway + from orm.domains import Domains + from orm import DB + rows = (DB.session.query(DomainSmtpGateway, Domains.domainname) + .join(Domains, Domains.ID == DomainSmtpGateway.domainID) + .order_by(Domains.domainname).all()) + if not rows: + cli.print(cli.col("No per-domain SMTP gateways configured.", "yellow")) + return 0 + cli.print("{:<6} {:<30} {:<25} {:<6} {:<10} {}".format( + "ID", "Domain", "Host", "Port", "Encryption", "Enabled")) + for gw, dname in rows: + cli.print("{:<6} {:<30} {:<25} {:<6} {:<10} {}".format( + gw.domainID, dname, gw.host, gw.port, gw.encryption, + "yes" if gw.enabled else "NO")) + return 0 diff --git a/endpoints/domain/__init__.py b/endpoints/domain/__init__.py index 47cef79..41debd4 100644 --- a/endpoints/domain/__init__.py +++ b/endpoints/domain/__init__.py @@ -1 +1 @@ -from . import folders, ldap, misc, mlists, users +from . import folders, ldap, misc, mlists, smtp_gateway, users diff --git a/endpoints/domain/smtp_gateway.py b/endpoints/domain/smtp_gateway.py new file mode 100644 index 0000000..23a9562 --- /dev/null +++ b/endpoints/domain/smtp_gateway.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 grommunio GmbH +# +# REST endpoints for per-domain SMTP gateway configuration. +# +# Routes: +# GET /api/v1/domains//smtpGateway +# PUT /api/v1/domains//smtpGateway +# DELETE /api/v1/domains//smtpGateway +# +# GET – Return the current gateway config (password is censored). +# PUT – Create or update the gateway config for the given domain. +# Body: JSON with fields host, port, encryption, username, +# password, enabled, description. +# DELETE – Remove the gateway config for the given domain (the MTA +# falls back to its default routing: relayhost/direct delivery). +# +# All routes require the DomainAdmin permission on the domain (write) or +# DomainAdminRO (read-only). + +import api +from api.core import API, secure +from api.security import checkPermissions + +from flask import request, jsonify +from tools.permissions import DomainAdminROPermission, DomainAdminPermission + +from orm.domains import Domains +from orm.domain_smtp_gateway import DomainSmtpGateway + + +def _mask_password(gw): + """Return a dict representation with the password field masked.""" + if gw is None: + return None + data = gw.todict(1) + if data.get("password"): + data["password"] = "" + data["passwordSet"] = True + else: + data["passwordSet"] = False + return data + + +@API.route(api.BaseRoute + "/domains//smtpGateway", methods=["GET"]) +@secure(requireDB=True) +def getDomainSmtpGateway(domainID): + domain = Domains.query.filter(Domains.ID == domainID).first() + if domain is None: + return jsonify(message="Domain not found"), 404 + checkPermissions(DomainAdminROPermission(domainID)) + + gw = DomainSmtpGateway.query.filter( + DomainSmtpGateway.domainID == domainID + ).first() + if gw is None: + return jsonify(message="No SMTP gateway configured for this domain"), 404 + return jsonify(data=_mask_password(gw)) + + +@API.route(api.BaseRoute + "/domains//smtpGateway", methods=["PUT"]) +@secure(requireDB=True) +def setDomainSmtpGateway(domainID): + domain = Domains.query.filter(Domains.ID == domainID).first() + if domain is None: + return jsonify(message="Domain not found"), 404 + checkPermissions(DomainAdminPermission(domainID)) + + data = request.get_json(silent=True) or {} + error = DomainSmtpGateway.checkCreateParams(data) + if error is not None: + return jsonify(message=error), 400 + + gw = DomainSmtpGateway.query.filter( + DomainSmtpGateway.domainID == domainID + ).first() + # The frontend sends a derived `passwordSet` flag that the ORM does + # not know about; drop it before constructing the model so we don't + # trip DataModel's strict fromdict validation. + data = {k: v for k, v in data.items() if k != "passwordSet"} + if gw is None: + data_with_id = {"domainID": domainID} + data_with_id.update({k: v for k, v in data.items() if v is not None}) + try: + gw = DomainSmtpGateway(data_with_id) + except (ValueError, TypeError) as err: + return jsonify(message=str(err)), 400 + else: + if not data.get("password"): + # Keep the existing password if the form submits an empty + # field (common UX for password fields). + data.pop("password", None) + gw.fromdict(data) + + from orm import DB + try: + if gw not in DB.session: + DB.session.add(gw) + DB.session.commit() + except Exception as err: + DB.session.rollback() + return jsonify(message="Database error: {}".format(err)), 500 + + return jsonify(message="Success!", data=_mask_password(gw)) + + +@API.route(api.BaseRoute + "/domains//smtpGateway", methods=["DELETE"]) +@secure(requireDB=True) +def deleteDomainSmtpGateway(domainID): + domain = Domains.query.filter(Domains.ID == domainID).first() + if domain is None: + return jsonify(message="Domain not found"), 404 + checkPermissions(DomainAdminPermission(domainID)) + + gw = DomainSmtpGateway.query.filter( + DomainSmtpGateway.domainID == domainID + ).first() + if gw is None: + return jsonify(message="No SMTP gateway configured for this domain"), 404 + from orm import DB + DB.session.delete(gw) + DB.session.commit() + return jsonify(message="Success!") diff --git a/orm/__init__.py b/orm/__init__.py index 4929b97..158a472 100644 --- a/orm/__init__.py +++ b/orm/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-FileCopyrightText: 2020 grommunio GmbH -__all__ = ["domains", "misc", "users", "ext"] +__all__ = ["domains", "misc", "users", "ext", "domain_smtp_gateway"] import sqlalchemy from sqlalchemy import create_engine, event, select, text diff --git a/orm/domain_smtp_gateway.py b/orm/domain_smtp_gateway.py new file mode 100644 index 0000000..25560a3 --- /dev/null +++ b/orm/domain_smtp_gateway.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 grommunio GmbH +# +# DomainSmtpGateway – per-domain outbound SMTP relay configuration. +# +# Allows each grommunio domain to specify its own smart-host / SMTP +# gateway (with optional authentication) for outbound mail delivery. +# The MTA (Postfix) evaluates the table with sender-dependent lookups; +# see doc/mta-smart-hosts.rst in gromox for the wiring. +# +# Mirror of the `domain_smtp_gateway` table that gromox's dbop module +# creates (schema version 134, see lib/dbop_mysql.cpp in gromox). +# Any change to the schema here must be reflected there and vice versa. + +from . import DB +from tools.DataModel import DataModel, Id, Text, Int, Bool + +from sqlalchemy import Column, ForeignKey +from sqlalchemy.dialects.mysql import INTEGER, TINYINT, VARCHAR + + +class DomainSmtpGateway(DataModel, DB.Base): + """Per-domain outbound SMTP gateway / smart-host configuration. + + The MTA evaluates this table live (sender-dependent lookups), so + changes take effect immediately -- no service restarts needed. + """ + + __tablename__ = "domain_smtp_gateway" + + domainID = Column( + "domain_id", + INTEGER(10, unsigned=True), + ForeignKey("domains.id", ondelete="cascade", onupdate="cascade"), + primary_key=True, + nullable=False, + ) + host = Column("host", VARCHAR(255), nullable=False) + port = Column("port", INTEGER(11), nullable=False, server_default="25") + encryption = Column( + "encryption", VARCHAR(32), nullable=False, server_default="none" + ) + username = Column("username", VARCHAR(255), nullable=True) + password = Column("password", VARCHAR(255), nullable=True) + enabled = Column("enabled", TINYINT(1), nullable=False, server_default="1") + description = Column("description", VARCHAR(255), nullable=True) + + _dictmapping_ = ( + (Id("domainID", flags="init"),), + ( + Text("host", flags="patch"), + Int("port", flags="patch"), + Text("encryption", flags="patch"), + Text("username", flags="patch"), + Text("password", flags="patch"), + Bool("enabled", flags="patch"), + Text("description", flags="patch"), + ), + ) + + VALID_ENCRYPTION = ("none", "starttls", "starttls_unverified", "tls") + + @staticmethod + def checkCreateParams(data): + """Validate input. Returns None on success or an error string.""" + if not data.get("host"): + return "Missing required property 'host'" + enc = data.get("encryption", "none") + if enc not in DomainSmtpGateway.VALID_ENCRYPTION: + return "'{}' is not a valid encryption mode (allowed: {})".format( + enc, ", ".join(DomainSmtpGateway.VALID_ENCRYPTION) + ) + port = data.get("port", 25) + try: + port = int(port) + if not (1 <= port <= 65535): + raise ValueError + except (TypeError, ValueError): + return "Port must be an integer between 1 and 65535" + return None diff --git a/res/openapi.yaml b/res/openapi.yaml index 788d0da..d7ddd74 100644 --- a/res/openapi.yaml +++ b/res/openapi.yaml @@ -3160,6 +3160,91 @@ paths: '503': $ref: '#/components/responses/ServiceUnavailable' + /domains/{domainID}/smtpGateway: + get: + summary: Get per-domain SMTP gateway configuration + operationId: getDomainSmtpGateway + tags: + - Domain Admin/SMTP Gateway + security: + - JWTCookie: [] + parameters: + - $ref: '#/components/parameters/domainID' + responses: + '200': + description: SMTP gateway configuration for the domain + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/domainSmtpGateway' + nullable: true + '400': + $ref: '#/components/responses/InvalidRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + put: + summary: Set or update per-domain SMTP gateway configuration + operationId: setDomainSmtpGateway + tags: + - Domain Admin/SMTP Gateway + security: + - JWTCookie: [] + parameters: + - $ref: '#/components/parameters/domainID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/domainSmtpGatewayWrite' + responses: + '200': + description: SMTP gateway saved + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + $ref: '#/components/schemas/domainSmtpGateway' + '400': + $ref: '#/components/responses/InvalidRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + delete: + summary: Remove per-domain SMTP gateway configuration + operationId: deleteDomainSmtpGateway + tags: + - Domain Admin/SMTP Gateway + security: + - JWTCookie: [] + parameters: + - $ref: '#/components/parameters/domainID' + responses: + '200': + description: SMTP gateway removed + '400': + $ref: '#/components/responses/InvalidRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /domains/{domainID}/users/{userID}/roles: patch: summary: Update user roles @@ -6514,6 +6599,77 @@ components: type: string nullable: true description: External out-of-office reply body + domainSmtpGateway: + type: object + description: Per-domain SMTP gateway (smart-host) configuration. + properties: + domainID: + type: integer + description: Foreign key to the domains table (primary key). + host: + type: string + description: Hostname or IP of the SMTP server. + port: + type: integer + description: TCP port of the SMTP server. + encryption: + type: string + enum: [none, starttls, starttls_unverified, tls] + description: Encryption mode used to talk to the SMTP server. + username: + type: string + nullable: true + description: Optional SMTP authentication username. + password: + type: string + nullable: true + description: | + Optional SMTP authentication password. The GET response + masks the value (returns empty string when set). + passwordSet: + type: boolean + description: True if a password is set in the database (read-only). + enabled: + type: boolean + description: Whether this gateway is active. + description: + type: string + nullable: true + description: Free-form description / note. + domainSmtpGatewayWrite: + type: object + description: | + Per-domain SMTP gateway write payload. Any field left out + is left unchanged. `host` is required when creating a new + entry. + required: [host] + properties: + host: + type: string + description: Hostname or IP of the SMTP server. + port: + type: integer + description: TCP port of the SMTP server. + encryption: + type: string + enum: [none, starttls, starttls_unverified, tls] + description: Encryption mode used to talk to the SMTP server. + username: + type: string + nullable: true + description: Optional SMTP authentication username. + password: + type: string + nullable: true + description: Optional SMTP authentication password. + enabled: + type: boolean + description: Whether this gateway is active. + description: + type: string + nullable: true + description: Free-form description. + responses: ServerError: description: An error occurred while processing the request @@ -6608,6 +6764,8 @@ tags: description: Endpoints for mailing list management - name: Domain Admin/Users description: Endpoints for user management + - name: Domain Admin/SMTP Gateway + description: Per-domain outbound SMTP gateway (smart-host) configuration - name: Defaults description: Endpoints providing default data - name: LDAP