Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions cli/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
173 changes: 173 additions & 0 deletions cli/domain_smtp_gateway.py
Original file line number Diff line number Diff line change
@@ -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 <DOMAIN> --host HOST [--port 587] [--encryption starttls]
# [--username USER] [--password PASS]
# [--disable] [--description "…"]
# Create or update the per-domain SMTP gateway config.
#
# show <DOMAIN>
# Print the current configuration (password is masked).
#
# delete <DOMAIN>
# 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
2 changes: 1 addition & 1 deletion endpoints/domain/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from . import folders, ldap, misc, mlists, users
from . import folders, ldap, misc, mlists, smtp_gateway, users
124 changes: 124 additions & 0 deletions endpoints/domain/smtp_gateway.py
Original file line number Diff line number Diff line change
@@ -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/<int:domainID>/smtpGateway
# PUT /api/v1/domains/<int:domainID>/smtpGateway
# DELETE /api/v1/domains/<int:domainID>/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/<int:domainID>/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/<int:domainID>/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/<int:domainID>/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!")
2 changes: 1 addition & 1 deletion orm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading