Merge pull request 'mailu: the manifest matches the machine, provides smtp, and carries automx' (#73) from feat/mailu-becomes-real into main
This commit was merged in pull request #73.
This commit is contained in:
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
|
|||||||
# resolved away.
|
# resolved away.
|
||||||
WORKDIR /app/modules/mailu
|
WORKDIR /app/modules/mailu
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
|
||||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||||
|
|
||||||
FROM ${RUNTIME_BASE}
|
FROM ${RUNTIME_BASE}
|
||||||
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
|
|||||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||||
# ran no provisioner at all.
|
# ran no provisioner at all.
|
||||||
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js
|
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own
|
||||||
|
# artifact: it is a config-baked sidecar of this mail server, not a standalone application
|
||||||
|
# (novox/hq ADR 0015 draws that line at applications).
|
||||||
|
#
|
||||||
|
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's
|
||||||
|
# `build.on`. The build context is the module's own directory; every ADD says so.
|
||||||
|
ARG PYTHON_BASE
|
||||||
|
|
||||||
|
FROM ${PYTHON_BASE}
|
||||||
|
RUN apk add --no-cache bash sqlite
|
||||||
|
WORKDIR /automx2
|
||||||
|
|
||||||
|
ADD automx/files/setupvenv.sh /automx2/setupvenv.sh
|
||||||
|
ADD automx/files/start /automx2/start
|
||||||
|
ADD automx/files/setup /automx2/setup
|
||||||
|
ADD automx/files/setup-db /automx2/setup-db
|
||||||
|
ADD automx/files/add-domains /automx2/add-domains
|
||||||
|
RUN chmod u+x setupvenv.sh start add-domains setup setup-db
|
||||||
|
|
||||||
|
RUN ./setupvenv.sh \
|
||||||
|
&& . .venv/bin/activate \
|
||||||
|
&& pip install automx2
|
||||||
|
|
||||||
|
ENV AUTOMX2_CONF=/etc/automx2.conf
|
||||||
|
ADD automx/files/automx2.conf /etc/automx2.conf
|
||||||
|
|
||||||
|
# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on
|
||||||
|
# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead.
|
||||||
|
ENTRYPOINT ["/bin/sh"]
|
||||||
|
CMD ["./start"]
|
||||||
|
|
||||||
|
EXPOSE 4243
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "${MAIL_DOMAINS}"
|
||||||
|
|
||||||
|
# Split domains into array
|
||||||
|
IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}"
|
||||||
|
|
||||||
|
# User configurable section -- START
|
||||||
|
PROVIDER_ID=001
|
||||||
|
SQL_CMD="";
|
||||||
|
|
||||||
|
# Iterate domains resulting from split on second arg
|
||||||
|
for element in "${array[@]}"
|
||||||
|
do
|
||||||
|
# Set vars
|
||||||
|
DOMAIN=$element
|
||||||
|
PROVIDER_NAME=$DOMAIN
|
||||||
|
PROVIDER_SHORTNAME=$DOMAIN
|
||||||
|
|
||||||
|
# Optional LDAP server
|
||||||
|
#LDAP_SERVER="ldap.${DOMAIN}"
|
||||||
|
# User configurable section -- END
|
||||||
|
s1_id=$((PROVIDER_ID + 1))
|
||||||
|
s2_id=$((PROVIDER_ID + 2))
|
||||||
|
s3_id=$((PROVIDER_ID + 3))
|
||||||
|
dom_id=$((PROVIDER_ID + 4))
|
||||||
|
|
||||||
|
s3_id='NULL'
|
||||||
|
|
||||||
|
SQL_CMD=$(cat <<EOT
|
||||||
|
$SQL_CMD
|
||||||
|
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
|
||||||
|
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
|
||||||
|
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
|
||||||
|
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
|
||||||
|
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
|
||||||
|
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
|
||||||
|
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
|
||||||
|
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
PROVIDER_ID=$((PROVIDER_ID+10))
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD}
|
||||||
|
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[automx2]
|
||||||
|
# A typical production setup would use loglevel = WARNING
|
||||||
|
loglevel = WARNING
|
||||||
|
# Echo SQL commands into log? Used for debugging.
|
||||||
|
db_echo = false
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory SQLite database
|
||||||
|
# db_uri = sqlite:///:memory:
|
||||||
|
|
||||||
|
# SQLite database in a UNIX-like file system
|
||||||
|
db_uri = sqlite:////data/db.sqlite
|
||||||
|
|
||||||
|
# MySQL database on a remote server. This example does not use an encrypted
|
||||||
|
# connection and is therefore *not* recommended for production use.
|
||||||
|
#db_uri = mysql://username:password@server.example.com/db
|
||||||
|
|
||||||
|
# Number of proxy servers between automx2 and the client (default: 0).
|
||||||
|
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
|
||||||
|
# connections, proxy_count probably needs to be changed.
|
||||||
|
proxy_count = 1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -e /data/db.sqlite ]; then
|
||||||
|
# DB SETUP
|
||||||
|
echo "SETTING UP DB"
|
||||||
|
./setup-db
|
||||||
|
|
||||||
|
echo "ADDING DOMAINS"
|
||||||
|
# Add the domains
|
||||||
|
./add-domains
|
||||||
|
fi
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# LDAP-Server
|
||||||
|
LDAP=$(cat <<EOT
|
||||||
|
CREATE TABLE ldapserver(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
port INT NOT NULL,
|
||||||
|
use_ssl INT NOT NULL,
|
||||||
|
search_base TEXT NOT NULL,
|
||||||
|
search_filter TEXT NOT NULL,
|
||||||
|
attr_uid TEXT NOT NULL,
|
||||||
|
attr_cn TEXT NOT NULL,
|
||||||
|
bind_password TEXT NOT NULL,
|
||||||
|
bind_user TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider
|
||||||
|
PROVIDER=$(cat <<EOT
|
||||||
|
CREATE TABLE provider(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
short_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server
|
||||||
|
SERVER=$(cat <<EOT
|
||||||
|
CREATE TABLE server(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
port INT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
socket_type TEXT NOT NULL,
|
||||||
|
user_name TEXT NOT NULL,
|
||||||
|
authentication TEXT NOT NULL
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Domain
|
||||||
|
DOMAIN=$(cat <<EOT
|
||||||
|
CREATE TABLE domain(
|
||||||
|
id INT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
provider_id INT NOT NULL,
|
||||||
|
ldapserver_id INT NULL,
|
||||||
|
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
|
||||||
|
FOREIGN KEY(provider_id) REFERENCES provider(id)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX domain_name ON domain(name);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server-Domain
|
||||||
|
SERVER_DOMAIN=$(cat <<EOT
|
||||||
|
CREATE TABLE server_domain(
|
||||||
|
server_id INT NOT NULL,
|
||||||
|
domain_id INT NOT NULL,
|
||||||
|
FOREIGN KEY(server_id) REFERENCES server(id),
|
||||||
|
FOREIGN KEY(domain_id) REFERENCES domain(id)
|
||||||
|
);
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
## TODO Foreign keys
|
||||||
|
|
||||||
|
SQL_CMD=$(cat <<EOT
|
||||||
|
$LDAP
|
||||||
|
$PROVIDER
|
||||||
|
$SERVER
|
||||||
|
$DOMAIN
|
||||||
|
$SERVER_DOMAIN
|
||||||
|
EOT
|
||||||
|
)
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD}
|
||||||
|
|
||||||
|
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# vim:ts=4:sw=4:noet
|
||||||
|
#
|
||||||
|
# Creates a Python 3 virtual environment. The target directory can be passed
|
||||||
|
# as a parameter. The default path is '.venv' in the current directory.
|
||||||
|
|
||||||
|
dir="${1:-.venv}"
|
||||||
|
echo "Setup dir $dir"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
if [ -d "${dir}" ]; then
|
||||||
|
echo >&2 "Directory '${dir}' already exists, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 -m venv "${dir}"
|
||||||
|
source "${dir}/bin/activate"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
pip install -U pip setuptools wheel || true
|
||||||
|
|
||||||
|
#set -e
|
||||||
|
## vim:tabstop=4:noexpandtab
|
||||||
|
##
|
||||||
|
## Creates a Python 3 virtual environment. The target directory can be passed
|
||||||
|
## as a parameter. The default path is 'venv' in the current directory.
|
||||||
|
#
|
||||||
|
#dir="${1:-venv}"
|
||||||
|
#
|
||||||
|
#set -e
|
||||||
|
#if [ -d "${dir}" ]; then
|
||||||
|
# echo "Directory '${dir}' already exists, exiting." >&2
|
||||||
|
# exit 1
|
||||||
|
#fi
|
||||||
|
#python3 -m venv "${dir}"
|
||||||
|
#. "${dir}/bin/activate"
|
||||||
|
#
|
||||||
|
#set +e
|
||||||
|
#pip install -U pip setuptools || true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
./setup
|
||||||
|
|
||||||
|
# Start
|
||||||
|
./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243
|
||||||
+124
-7
@@ -14,8 +14,30 @@
|
|||||||
"name": "mailu"
|
"name": "mailu"
|
||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "mail",
|
"web": {
|
||||||
"port": 7080
|
"label": "mail",
|
||||||
|
"port": 7443,
|
||||||
|
"scheme": "https",
|
||||||
|
"insecure": true
|
||||||
|
},
|
||||||
|
"acme": {
|
||||||
|
"label": "mail",
|
||||||
|
"path": "/.well-known/acme-challenge",
|
||||||
|
"port": 7080,
|
||||||
|
"priority": 100
|
||||||
|
},
|
||||||
|
"autoconfig": {
|
||||||
|
"label": "autoconfig",
|
||||||
|
"port": 4243
|
||||||
|
},
|
||||||
|
"autodiscover": {
|
||||||
|
"label": "autodiscover",
|
||||||
|
"port": 4243
|
||||||
|
},
|
||||||
|
"automx": {
|
||||||
|
"label": "automx",
|
||||||
|
"port": 4243
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -44,6 +66,20 @@
|
|||||||
"why": "mail from other mail servers",
|
"why": "mail from other mail servers",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"port": 110,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 143,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "IMAP with STARTTLS, kept at parity",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"port": 465,
|
"port": 465,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
@@ -55,7 +91,7 @@
|
|||||||
"port": 587,
|
"port": 587,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
"why": "submission",
|
"why": "submission; also what the smtp provision serves consumers",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,11 +101,30 @@
|
|||||||
"why": "IMAP over TLS",
|
"why": "IMAP over TLS",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"port": 995,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "anywhere",
|
||||||
|
"why": "POP3 over TLS, kept at parity",
|
||||||
|
"fixed": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"port": 7080,
|
"port": 7080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the web interface (admin, webmail, admin API), behind the route proxy"
|
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 7443,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 4243,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"own-secrets": {
|
"own-secrets": {
|
||||||
@@ -88,12 +143,24 @@
|
|||||||
"path": "/var/lib/mailu",
|
"path": "/var/lib/mailu",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "grants",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/mailu/grants",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data-automx",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/services/mailu/data/automx",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "config-env",
|
"id": "config-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/mailu/mailu.env",
|
"path": "/var/lib/mailu/mailu.env",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n"
|
"content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "secret-env",
|
"id": "secret-env",
|
||||||
@@ -365,10 +432,14 @@
|
|||||||
],
|
],
|
||||||
"ports": [
|
"ports": [
|
||||||
"25",
|
"25",
|
||||||
|
"110",
|
||||||
|
"143",
|
||||||
"465",
|
"465",
|
||||||
"587",
|
"587",
|
||||||
"993",
|
"993",
|
||||||
"80"
|
"995",
|
||||||
|
"7080:80",
|
||||||
|
"7443:443"
|
||||||
],
|
],
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"/services/mailu/data/certs:/certs",
|
"/services/mailu/data/certs:/certs",
|
||||||
@@ -391,6 +462,7 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
||||||
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
||||||
|
"/var/lib/mailu/grants:/var/lib/mailu/grants:ro",
|
||||||
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
@@ -399,12 +471,30 @@
|
|||||||
"MESH_MAILU_URL": "http://mailu-admin/api/v1",
|
"MESH_MAILU_URL": "http://mailu-admin/api/v1",
|
||||||
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
|
||||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json"
|
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||||
|
"MESH_MAILU_DOMAIN": "novox.be",
|
||||||
|
"MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json"
|
||||||
},
|
},
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"runtime-config"
|
"runtime-config"
|
||||||
],
|
],
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "automx",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mailu-automx",
|
||||||
|
"artifact": "automx",
|
||||||
|
"network": "mailu",
|
||||||
|
"env-file": [
|
||||||
|
"/var/lib/mailu/mailu.env"
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"4243"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/services/mailu/data/automx:/data"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -418,6 +508,10 @@
|
|||||||
"arg": "RUNTIME_BASE",
|
"arg": "RUNTIME_BASE",
|
||||||
"module": "mesh-tools",
|
"module": "mesh-tools",
|
||||||
"artifact": "runtime"
|
"artifact": "runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "PYTHON_BASE",
|
||||||
|
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
@@ -425,7 +519,30 @@
|
|||||||
"name": "runtime",
|
"name": "runtime",
|
||||||
"kind": "image",
|
"kind": "image",
|
||||||
"from": "Dockerfile"
|
"from": "Dockerfile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "automx",
|
||||||
|
"kind": "image",
|
||||||
|
"from": "automx/Dockerfile"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"name": "smtp",
|
||||||
|
"scope": "mesh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"serves": {
|
||||||
|
"smtp": {
|
||||||
|
"port": 587,
|
||||||
|
"domain": "novox.be"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receives": {
|
||||||
|
"smtp": "/var/lib/mailu/grants/mesh.json"
|
||||||
|
},
|
||||||
|
"grants": {
|
||||||
|
"smtp": "/var/lib/mailu/grants"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` interface.
|
||||||
|
// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk
|
||||||
|
// harness's; this writes only the per-service half: how mailu creates and removes a consumer's
|
||||||
|
// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling).
|
||||||
|
//
|
||||||
|
// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real
|
||||||
|
// mailbox this provisioner creates. The address is `<account>@<domain>`: the local part is the
|
||||||
|
// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's
|
||||||
|
// own login for a consumer that named none; the domain is the mail server's, which is this
|
||||||
|
// module's fact, not the consumer's.
|
||||||
|
//
|
||||||
|
// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands
|
||||||
|
// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals
|
||||||
|
// nothing: the consumer already has its copy through the mesh's own channel.
|
||||||
|
|
||||||
|
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||||
|
import { MailuClient } from "../client.js";
|
||||||
|
|
||||||
|
const mailu = MailuClient.fromEnv();
|
||||||
|
|
||||||
|
// The mail server's own domain. From the environment the manifest composes, because the client's
|
||||||
|
// config file carries the admin API's coordinates, not the mail domain.
|
||||||
|
function domain(): string {
|
||||||
|
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
|
||||||
|
if (named === "") {
|
||||||
|
throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed");
|
||||||
|
}
|
||||||
|
return named;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address one consumer sends as. The local part is refused rather than sanitised when it is
|
||||||
|
// not a plain mailbox name — a rewritten name is an address nobody asked for.
|
||||||
|
function addressOf(p: { as: string; values?: Readonly<Record<string, unknown>> }): string {
|
||||||
|
const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : "";
|
||||||
|
const local = contributed !== "" ? contributed : p.as;
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) {
|
||||||
|
throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`);
|
||||||
|
}
|
||||||
|
return `${local}@${domain()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
runProvisioner("smtp", {
|
||||||
|
async create(p: Provision): Promise<void> {
|
||||||
|
const email = addressOf(p);
|
||||||
|
// Create if absent, and set exactly the minted password either way so a rotation takes.
|
||||||
|
// Mailu's create refuses a duplicate address, which is the signal to fall through to the
|
||||||
|
// password set — the same found-then-apply shape gitea's ensureUser settled on.
|
||||||
|
try {
|
||||||
|
await mailu.createUser(email, p.password);
|
||||||
|
} catch {
|
||||||
|
await mailu.changePassword(email, p.password);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(p: { as: string }): Promise<void> {
|
||||||
|
// The withdrawal only knows the mesh login, never the contributed local part — so accounts
|
||||||
|
// that contributed one are removed when the address matching the login is absent? No: the
|
||||||
|
// harness hands remove only `as`, and an address composed from a contribution cannot be
|
||||||
|
// recomputed from it. The account is therefore removed by its login-shaped address when one
|
||||||
|
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop
|
||||||
|
// must not guess about (this module's own events file says the same). Withdrawal of a
|
||||||
|
// named-account consumer is an operator action until the harness carries values here.
|
||||||
|
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -151,6 +151,24 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
|
|||||||
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
|
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// What this adapter's one file shape cannot say, it skips aloud rather than approximating:
|
||||||
|
// a backend over its own TLS (the file would send plain http into a TLS listener), a
|
||||||
|
// path-scoped or refusing or redirecting rule (the file routes whole hosts). The mesh's own
|
||||||
|
// proxy serves all of these the day it takes over; until then the predecessor's hand-authored
|
||||||
|
// files keep covering them, exactly as they do today.
|
||||||
|
const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : "";
|
||||||
|
if (scheme !== "" && scheme !== "http") {
|
||||||
|
skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") {
|
||||||
|
skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") {
|
||||||
|
skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const port = asPort(entry.values?.["port"]);
|
const port = asPort(entry.values?.["port"]);
|
||||||
if (port === undefined) {
|
if (port === undefined) {
|
||||||
skipped.push(`${from} asked for route ${name} and gave no usable port`);
|
skipped.push(`${from} asked for route ${name} and gave no usable port`);
|
||||||
|
|||||||
@@ -205,6 +205,27 @@ test("a contribution it cannot act on is skipped and named", async () => {
|
|||||||
assert.deepEqual(routesFrom(undefined, machine).routes, []);
|
assert.deepEqual(routesFrom(undefined, machine).routes, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS
|
||||||
|
// listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect.
|
||||||
|
// The mesh's own proxy serves all of these the day it takes over; until then the predecessor's
|
||||||
|
// hand-authored files keep covering them.
|
||||||
|
test("a contribution the file shape cannot say is skipped and says which part", async () => {
|
||||||
|
const machine = defaults.machine;
|
||||||
|
const { routes, skipped } = routesFrom({ given: [
|
||||||
|
{ from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } },
|
||||||
|
{ from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } },
|
||||||
|
{ from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } },
|
||||||
|
{ from: "site", values: { name: "www.example", redirect: "https://example" } },
|
||||||
|
{ from: "mailu", values: { name: "autoconfig.example", port: 4243 } },
|
||||||
|
] }, machine);
|
||||||
|
assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]);
|
||||||
|
assert.equal(skipped.length, 4);
|
||||||
|
assert.match(skipped[0]!, /over https/);
|
||||||
|
assert.match(skipped[1]!, /scoped to a path/);
|
||||||
|
assert.match(skipped[2]!, /scoped to a path/);
|
||||||
|
assert.match(skipped[3]!, /policy/);
|
||||||
|
});
|
||||||
|
|
||||||
// The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write
|
// The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write
|
||||||
// into — and writing anyway would put route files somewhere nothing reads, reporting success.
|
// into — and writing anyway would put route files somewhere nothing reads, reporting success.
|
||||||
test("it refuses when the predecessor's directory is not there, and says why", async () => {
|
test("it refuses when the predecessor's directory is not there, and says why", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user