diff --git a/Makefile b/Makefile index b1da4341..5b8d63be 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -COMPONENTS := agent mie-opensource-landing manager-control-program create-a-container +COMPONENTS := agent mail-helper mie-opensource-landing manager-control-program create-a-container PACKAGER ?= deb # Forwarded to every component Makefile. diff --git a/compose.yml b/compose.yml index 66cd77b2..8e0c07d1 100644 --- a/compose.yml +++ b/compose.yml @@ -42,6 +42,7 @@ services: - | npm -C create-a-container ci --no-audit --no-fund make -C agent build + make -C mail-helper build # Watches the React client and rebuilds the production bundle on change. The # Manager LXC inside Proxmox serves create-a-container/client/dist statically @@ -136,6 +137,12 @@ services: - 127.0.0.1:443:443 - 127.0.0.1:8006:8006 - 127.0.0.1:1080:1080 # maildev web UI (maildev shares this network namespace) + # Mail (issue #67): DNATed to the manager CT like 80/443. + - 127.0.0.1:25:25 + - 127.0.0.1:143:143 + - 127.0.0.1:465:465 + - 127.0.0.1:587:587 + - 127.0.0.1:993:993 hostname: proxmox domainname: cluster.internal dns: @@ -197,7 +204,8 @@ services: curl -H "X-CSRF-Token: $${CSRF}" --json '{"apiUrl":"https://10.254.0.1:8006","username":"root@pam","password":"root","tlsVerify":false}' https://proxmox/api/v1/sites/1/nodes/import curl -H "X-CSRF-Token: $${CSRF}" --json '{"name":"localhost","siteId":1,"acmeEmail":"","acmeDirectoryUrl":"","cloudflareApiEmail":"","cloudflareApiKey":"","authServer":""}' https://proxmox/api/v1/external-domains # Point SMTP at maildev only when unset, so a developer's own SMTP setting survives. PUT replaces every setting, hence the GET round-trip. - curl https://proxmox/api/v1/settings | jq '.data | select(.smtpUrl == "") | .smtpUrl = "smtp://10.254.0.1:1025" | .smtpNoreplyAddress |= if . == "" then "noreply@cluster.internal" else . end' > settings.json + # Mail service (issue #67): maildev doubles as the relayhost so outbound mail lands in http://localhost:1080; mailSpfInclude is required alongside a relayhost (dev placeholder). + curl https://proxmox/api/v1/settings | jq '.data | select(.smtpUrl == "" or .mailRelayhost == "") | .smtpUrl |= if . == "" then "smtp://10.254.0.1:1025" else . end | .smtpNoreplyAddress |= if . == "" then "noreply@cluster.internal" else . end | .mailRelayhost |= if . == "" then "[10.254.0.1]:1025" else . end | .mailSpfInclude |= if . == "" then "maildev.localhost" else . end | .mailHostname |= if . == "" then "localhost" else . end' > settings.json [ -s settings.json ] && curl -H "X-CSRF-Token: $${CSRF}" -X PUT --json @settings.json https://proxmox/api/v1/settings curl -H "X-CSRF-Token: $${CSRF}" -X PUT --json '{"hostname":"manager","template":"","entrypoint":"","nvidiaRequested":false,"services":{"s0":{"type":"http","internalPort":3000,"externalHostname":"manager","externalDomainId":1,"dnsName":"","authRequired":false}},"environmentVars":[],"restart":false}' https://proxmox/api/v1/sites/1/containers/1 depends_on: diff --git a/images/manager/Dockerfile b/images/manager/Dockerfile index d8d7a38a..199823a1 100644 --- a/images/manager/Dockerfile +++ b/images/manager/Dockerfile @@ -3,9 +3,11 @@ FROM agent # Default environment for the site agent. The manager runs the app on # localhost so it doesn't need an API key. Remote agents must set these -# explicitly. +# explicitly. The manager is the default mail host (issue #67): its agent +# reports the `mail` service group and takes the mail-host claim. ENV SITE_ID=1 ENV MANAGER_URL=http://localhost:3000 +ENV AGENT_SERVICES=nginx,dnsmasq,mail # Install Postgres 18 from the PGDG repository and enable remote access only # for TLS encrypted connections. Debian creates a self-signed cert for this so @@ -23,6 +25,23 @@ RUN apt-get update && apt-get -y install postgresql-common \ # override prevents a race condition at boot. COPY images/manager/wait-proxmox-regenerate-snakeoil.conf /etc/systemd/system/postgresql@.service.d/ +# Mail stack (issue #67): Postfix + Dovecot + OpenDKIM, configured entirely by +# the agent once it takes the mail-host claim — preseed "No configuration" and +# keep the units disabled until then. Dovecot/Postfix read accounts live from +# Postgres over the local socket: pg_ident maps their OS users onto the +# read-only mail_* roles created by setup-mail-db-roles.sh (peer auth, no +# passwords). The pg_hba rules must precede the catch-all "local all all peer". +RUN echo 'postfix postfix/main_mailer_type select No configuration' | debconf-set-selections && \ + apt-get update && \ + apt-get -y install postfix postfix-pgsql dovecot-core dovecot-imapd dovecot-lmtpd dovecot-pgsql opendkim && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + systemctl disable postfix dovecot opendkim && \ + sed -i '1i # Mail stack peer-auth maps (opensource-server issue #67)\nlocal all mail_postfix peer map=osmail\nlocal all mail_dovecot peer map=osmail' /etc/postgresql/18/main/pg_hba.conf && \ + { echo 'osmail postfix mail_postfix'; \ + echo 'osmail opensource-mail mail_postfix'; \ + echo 'osmail dovecot mail_dovecot'; } >>/etc/postgresql/18/main/pg_ident.conf + # First-boot database initialization. COPY images/manager/container-creator-init.service /etc/systemd/system/container-creator-init.service @@ -32,7 +51,14 @@ COPY images/manager/container-creator-init.service /etc/systemd/system/container # enable the init service. RUN --mount=from=builder,source=/dist,target=/dist \ apt-get update && \ - apt-get install -y /dist/opensource-docs_*.deb /dist/opensource-mcp_*.deb /dist/opensource-server_*.deb && \ + apt-get install -y /dist/opensource-docs_*.deb /dist/opensource-mcp_*.deb /dist/opensource-server_*.deb /dist/opensource-mail-helper_*.deb && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ systemctl enable container-creator-init.service + +# Mail ports: SMTP, IMAP(S), submission (STARTTLS + implicit TLS). +EXPOSE 25 +EXPOSE 143 +EXPOSE 465 +EXPOSE 587 +EXPOSE 993 diff --git a/images/manager/container-creator-init.service b/images/manager/container-creator-init.service index 32082007..fd459934 100644 --- a/images/manager/container-creator-init.service +++ b/images/manager/container-creator-init.service @@ -18,6 +18,7 @@ ExecStart=/bin/bash -e -c '\ export POSTGRES_HOST="localhost"; \ sudo -u postgres psql -c "CREATE USER $${POSTGRES_USER} WITH PASSWORD '"'"'$${POSTGRES_PASSWORD}'"'"';"; \ sudo -u postgres psql -c "CREATE DATABASE $${POSTGRES_DATABASE} OWNER $${POSTGRES_USER};"; \ + sudo -u postgres psql -c "ALTER ROLE $${POSTGRES_USER} CREATEROLE;"; \ echo "DATABASE_DIALECT=$${DATABASE_DIALECT}" > /etc/default/container-creator; \ echo "POSTGRES_HOST=$${POSTGRES_HOST}" >> /etc/default/container-creator; \ echo "POSTGRES_DATABASE=$${POSTGRES_DATABASE}" >> /etc/default/container-creator; \ diff --git a/images/proxmox-ve/interfaces b/images/proxmox-ve/interfaces index bc7f6296..fa23d106 100644 --- a/images/proxmox-ve/interfaces +++ b/images/proxmox-ve/interfaces @@ -21,8 +21,8 @@ iface vmbr0 inet static post-up echo 1 > /proc/sys/net/ipv4/ip_forward post-up iptables -t nat -A POSTROUTING -s 10.254.0.0/16 -o eth0 -j MASQUERADE - post-up iptables -t nat -A PREROUTING -i eth0 -p tcp -m multiport --dports 80,443,2000:2999 -j DNAT --to-destination 10.254.0.2 + post-up iptables -t nat -A PREROUTING -i eth0 -p tcp -m multiport --dports 80,443,2000:2999,25,143,465,587,993 -j DNAT --to-destination 10.254.0.2 post-up iptables -t nat -A PREROUTING -i eth0 -p udp -m multiport --dports 80,443,2000:2999 -j DNAT --to-destination 10.254.0.2 post-down iptables -t nat -D POSTROUTING -s 10.254.0.0/16 -o eth0 -j MASQUERADE - post-down iptables -t nat -D PREROUTING -i eth0 -p tcp -m multiport --dports 80,443,2000:2999 -j DNAT --to-destination 10.254.0.2 + post-down iptables -t nat -D PREROUTING -i eth0 -p tcp -m multiport --dports 80,443,2000:2999,25,143,465,587,993 -j DNAT --to-destination 10.254.0.2 post-down iptables -t nat -D PREROUTING -i eth0 -p udp -m multiport --dports 80,443,2000:2999 -j DNAT --to-destination 10.254.0.2 diff --git a/mail-helper/.fpm b/mail-helper/.fpm new file mode 100644 index 00000000..23737e35 --- /dev/null +++ b/mail-helper/.fpm @@ -0,0 +1,11 @@ +--name opensource-mail-helper +--architecture all +--license Apache-2.0 +--maintainer "Medical Informatics Engineering " +--vendor "Medical Informatics Engineering" +--url "https://github.com/mieweb/opensource-server" +--category mail +--description "MIE opensource-server mail helper: a Postfix milter that enforces one-click unsubscribe suppressions at RCPT and injects RFC 8058 List-Unsubscribe headers before DKIM signing. Managed by the opensource-agent mail service group." +--depends nodejs +--deb-no-default-config-files +--after-install contrib/postinstall.sh diff --git a/mail-helper/.gitignore b/mail-helper/.gitignore new file mode 100644 index 00000000..0e59bd6c --- /dev/null +++ b/mail-helper/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +config.local.json +src/unsubscribe-token.js +.pkg/ +*.deb +*.rpm +*.apk diff --git a/mail-helper/Makefile b/mail-helper/Makefile new file mode 100644 index 00000000..d9e34565 --- /dev/null +++ b/mail-helper/Makefile @@ -0,0 +1,73 @@ +.DEFAULT_GOAL := help + +PREFIX ?= /opt/opensource-server +DESTDIR ?= / +DESTBIN := $(DESTDIR)$(PREFIX)/mail-helper +UNIT_DIR := $(DESTDIR)/usr/lib/systemd/system + +# Staging directory for packaging +STAGE := $(CURDIR)/.pkg/buildroot + +INSTALL := install +INSTALL_DATA := $(INSTALL) -m 0644 + +# Canonical unsubscribe-token implementation lives in the manager; it is +# copied here at build time (gitignored) so the two stay in sync by +# construction instead of by hand. +TOKEN_SRC := ../create-a-container/utils/unsubscribe-token.js + +.PHONY: help deps build dev test install deb rpm apk package clean + +help: + @echo "mail-helper — builds the opensource-mail-helper package." + @echo "" + @echo "Targets:" + @echo " deps install dependencies (npm ci) and sync shared sources" + @echo " build prepare the runtime tree" + @echo " test run tests" + @echo " install stage files into DESTDIR (default /)" + @echo " deb build the .deb package" + @echo " rpm build the .rpm package" + @echo " apk build the .apk package" + @echo " clean remove build output, staging dir and built packages" + @echo " help show this message" + @echo "" + @echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)." + +deps: + npm ci + cp $(TOKEN_SRC) src/unsubscribe-token.js + +build: deps + npm prune --omit=dev + +dev: build + +test: deps + npm test + +install: build + $(INSTALL) -d $(DESTBIN)/src + $(INSTALL_DATA) package.json config.json $(DESTBIN)/ + $(INSTALL_DATA) src/*.js $(DESTBIN)/src/ + cp -a node_modules $(DESTBIN)/ + $(INSTALL) -d $(UNIT_DIR) + $(INSTALL_DATA) contrib/systemd/opensource-mail-helper.service $(UNIT_DIR)/ + +PACKAGER ?= deb +package: + rm -rf $(STAGE) + $(MAKE) install DESTDIR=$(STAGE) PREFIX=$(PREFIX) + fpm -s dir -t $(PACKAGER) -v "$$(../package-version $(PACKAGER))" \ + -C $(STAGE) -p $(CURDIR) -f + +deb: + $(MAKE) package PACKAGER=deb +rpm: + $(MAKE) package PACKAGER=rpm +apk: + $(MAKE) package PACKAGER=apk + +clean: + rm -rf $(STAGE) node_modules + rm -f src/unsubscribe-token.js *.deb *.rpm *.apk diff --git a/mail-helper/README.md b/mail-helper/README.md new file mode 100644 index 00000000..52f5371e --- /dev/null +++ b/mail-helper/README.md @@ -0,0 +1,28 @@ +# opensource-mail-helper + +A Postfix [milter](https://www.postfix.org/MILTER_README/) for the mail service (issue #67). It runs pre-queue on the submission ports, listening on a unix socket inside the Postfix chroot, ordered before OpenDKIM so the DKIM signature covers the headers it adds: + +- **RCPT**: rejects recipients who one-click-unsubscribed from the authenticated sender (`mail_suppressions_v`) with a synchronous `5.7.1`; database outages defer (fail closed). +- **End of message**: strips client-forged `List-Unsubscribe` headers and, for single-recipient submissions, injects RFC 8058 one-click headers with an encrypted per-recipient token. + +## Layout + +| Path | Purpose | +|------|---------| +| `src/index.js` | Daemon entry — milter server wiring | +| `src/filter.js` | Decision logic (unit-tested, no milter plumbing) | +| `src/db.js` | Read-only access to the mail views as `mail_postfix` | +| `src/config.js` | `config.json` + `config.local.json` overlay loading | +| `src/unsubscribe-token.js` | Copied from `create-a-container/utils/unsubscribe-token.js` by `make deps` (gitignored) — single source of truth for the token format | +| `config.json` | Committed defaults (not a dpkg conffile) | +| `config.local.json` | Environment overlay, written by the agent's mail service group (gitignored) | + +## Build and test + +```bash +make deps # npm ci + sync shared sources +make test # node --test +make deb # package (installs to /opt/opensource-server/mail-helper) +``` + +The package is suggested by `opensource-agent`; the agent enables/starts the unit only on the host holding the mail-host claim. A [self-managed MTA](../mie-opensource-landing/docs/admins/email.md) can run the daemon standalone — it only needs the DB views and the unsubscribe base URL + keys in `config.local.json`. diff --git a/mail-helper/config.json b/mail-helper/config.json new file mode 100644 index 00000000..61880911 --- /dev/null +++ b/mail-helper/config.json @@ -0,0 +1,15 @@ +{ + "socket": "/var/spool/postfix/mail-helper/milter.sock", + "db": { + "dialect": "postgres", + "host": null, + "port": null, + "database": null, + "user": "mail_postfix", + "password": null + }, + "unsubscribe": { + "baseUrl": null, + "keys": [] + } +} diff --git a/mail-helper/contrib/postinstall.sh b/mail-helper/contrib/postinstall.sh new file mode 100644 index 00000000..46aeac66 --- /dev/null +++ b/mail-helper/contrib/postinstall.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +# System users for the mail stack. vmail owns the Maildirs (Dovecot +# mail_uid/mail_gid); opensource-mail runs the milter daemon. The agent's +# mail service group manages directories, config, and the unit itself. +if ! id -u vmail >/dev/null 2>&1; then + useradd --system --home-dir /var/vmail --create-home --shell /usr/sbin/nologin vmail +fi +if ! id -u opensource-mail >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin opensource-mail +fi + +command -v systemctl >/dev/null 2>&1 || exit 0 +if [ -d /run/systemd/system ]; then + systemctl daemon-reload +fi + +exit 0 diff --git a/mail-helper/contrib/systemd/opensource-mail-helper.service b/mail-helper/contrib/systemd/opensource-mail-helper.service new file mode 100644 index 00000000..8a6c03f0 --- /dev/null +++ b/mail-helper/contrib/systemd/opensource-mail-helper.service @@ -0,0 +1,23 @@ +[Unit] +Description=MIE opensource-server mail helper (unsubscribe milter) +Documentation=https://github.com/mieweb/opensource-server/issues/67 +After=network.target +PartOf=postfix.service + +[Service] +Type=simple +User=opensource-mail +# postfix group so the chrooted smtpd can connect to the milter socket. +Group=postfix +WorkingDirectory=/opt/opensource-server/mail-helper +ExecStart=/usr/bin/node /opt/opensource-server/mail-helper/src/index.js +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/var/spool/postfix/mail-helper + +[Install] +WantedBy=multi-user.target diff --git a/mail-helper/package-lock.json b/mail-helper/package-lock.json new file mode 100644 index 00000000..9de070a0 --- /dev/null +++ b/mail-helper/package-lock.json @@ -0,0 +1,720 @@ +{ + "name": "opensource-mail-helper", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opensource-mail-helper", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "milter": "^3.0.0", + "mysql2": "^3.15.3", + "pg": "^8.16.3" + }, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-logotype": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-logotype/-/asn1-x509-logotype-2.8.0.tgz", + "integrity": "sha512-79px79a3YB8OnzVh+hbUlJKNaWymsf+im26AsgHnOhZ1859qVR4P0C4JBq/52dF8LXzNfvioqIOew1Xg7l2nDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@postalsys/vmc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@postalsys/vmc/-/vmc-1.1.5.tgz", + "integrity": "sha512-WwleGWC51o/VWQFTId6xMiO9aqkIVCM7ODy6UWfzFoQ6UzXewsNrm3ANa4HbBcyYaTK6YPvp4Gt0U+1cXLTbzw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "2.8.0", + "@peculiar/asn1-x509": "2.8.0", + "@peculiar/asn1-x509-logotype": "2.8.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz", + "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mailauth": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/mailauth/-/mailauth-5.0.3.tgz", + "integrity": "sha512-eAOpTwU2zKDCOusM6AIxl9ZZqkUWUoyoE+Ax3HzqdPs6R8xL/DqqbjpSFB14DUuBywKfrBLCa7qOE9++1V3h/Q==", + "license": "MIT", + "dependencies": { + "@postalsys/vmc": "1.1.5", + "commander": "15.0.0", + "fast-xml-parser": "5.10.1", + "ipaddr.js": "2.5.0", + "joi": "18.2.3", + "libmime": "5.4.1", + "nodemailer": "9.0.4", + "punycode.js": "2.3.1", + "tldts": "7.4.10", + "undici": "8.10.0" + }, + "bin": { + "mailauth": "bin/mailauth.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/milter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/milter/-/milter-3.0.0.tgz", + "integrity": "sha512-MqwG/W2UFS6wpRSoB44ItjlRIA8vUrcJe+j04WPSUvmmBwx+vivffBW3OCDgpC1/tKgRPCarW7JqPJEcBUD6tg==", + "license": "MIT", + "dependencies": { + "mailauth": "^5.0.3" + }, + "engines": { + "node": ">=22.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/mysql2": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz", + "integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nodemailer": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz", + "integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "peer": true + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/mail-helper/package.json b/mail-helper/package.json new file mode 100644 index 00000000..e0f43519 --- /dev/null +++ b/mail-helper/package.json @@ -0,0 +1,20 @@ +{ + "name": "opensource-mail-helper", + "version": "0.0.0", + "private": true, + "description": "MIE opensource-server mail helper: a Postfix milter that enforces one-click unsubscribe suppressions at RCPT and injects RFC 8058 List-Unsubscribe headers before DKIM signing.", + "license": "Apache-2.0", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "node --test \"test/**/*.test.js\"" + }, + "dependencies": { + "milter": "^3.0.0", + "mysql2": "^3.15.3", + "pg": "^8.16.3" + }, + "engines": { + "node": ">=20.18.1" + } +} diff --git a/mail-helper/src/config.js b/mail-helper/src/config.js new file mode 100644 index 00000000..400f30b0 --- /dev/null +++ b/mail-helper/src/config.js @@ -0,0 +1,41 @@ +/** + * Config loading: the committed ./config.json ships defaults; the agent (or + * a developer) overlays environment-specific values in ./config.local.json — + * the same split in development and production. MAIL_HELPER_DIR overrides + * the directory for tests and dev stacks. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + if (err.code === 'ENOENT') return null; + throw new Error(`mail-helper: cannot read ${file}: ${err.message}`); + } +} + +/** Plain-object deep merge; local values win, arrays replace. */ +function merge(base, overlay) { + if (overlay === null || overlay === undefined) return base; + if (Array.isArray(base) || Array.isArray(overlay) || typeof base !== 'object' || typeof overlay !== 'object') { + return overlay; + } + const out = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + out[key] = merge(base[key], value); + } + return out; +} + +function loadConfig(dir = process.env.MAIL_HELPER_DIR || path.join(__dirname, '..')) { + const defaults = readJson(path.join(dir, 'config.json')); + if (!defaults) throw new Error(`mail-helper: missing config.json in ${dir}`); + return merge(defaults, readJson(path.join(dir, 'config.local.json'))); +} + +module.exports = { loadConfig, merge }; diff --git a/mail-helper/src/db.js b/mail-helper/src/db.js new file mode 100644 index 00000000..08fdf193 --- /dev/null +++ b/mail-helper/src/db.js @@ -0,0 +1,61 @@ +/** + * Thin dialect-agnostic access to the read-only mail views (mail_senders_v, + * mail_suppressions_v) as the mail_postfix role. No host means the local + * unix socket (Postgres peer / MariaDB socket auth — no password). + */ + +'use strict'; + +class Db { + constructor(cfg) { + if (cfg.dialect === 'mysql') { + const mysql = require('mysql2/promise'); + const pool = mysql.createPool({ + ...(cfg.host ? { host: cfg.host, port: cfg.port ?? 3306 } : { socketPath: '/var/run/mysqld/mysqld.sock' }), + user: cfg.user, + ...(cfg.password ? { password: cfg.password } : {}), + database: cfg.database, + connectionLimit: 5, + }); + this.pool = { + async query(sql, params) { + const [rows] = await pool.query(sql.replace(/\$\d+/g, '?'), params); + return { rows }; + }, + }; + } else { + const { Pool } = require('pg'); + this.pool = new Pool({ + ...(cfg.host ? { host: cfg.host, port: cfg.port ?? 5432 } : { host: '/var/run/postgresql' }), + user: cfg.user, + ...(cfg.password ? { password: cfg.password } : {}), + database: cfg.database ?? undefined, + max: 5, + }); + } + } + + /** Sender account info, or null when the address is not a managed sender. */ + async getSender(address) { + const { rows } = await this.pool.query( + 'SELECT account_id, unsubscribe_headers FROM mail_senders_v WHERE sender = $1', + [String(address).toLowerCase()], + ); + if (!rows[0]) return null; + const raw = rows[0].unsubscribe_headers; // boolean on Postgres, 0/1 on MySQL + return { + accountId: String(rows[0].account_id), + unsubscribeHeaders: raw === true || raw === 1 || raw === '1', + }; + } + + async isSuppressed(sender, recipient) { + const { rows } = await this.pool.query( + 'SELECT 1 AS hit FROM mail_suppressions_v WHERE sender = $1 AND recipient = $2', + [String(sender).toLowerCase(), String(recipient).toLowerCase()], + ); + return rows.length > 0; + } +} + +module.exports = { Db }; diff --git a/mail-helper/src/filter.js b/mail-helper/src/filter.js new file mode 100644 index 00000000..7a5c4a4b --- /dev/null +++ b/mail-helper/src/filter.js @@ -0,0 +1,63 @@ +/** + * Filter decisions, kept free of milter/server plumbing so they are unit + * testable. The milter runs pre-queue on the submission ports only, where + * reject_sender_login_mismatch enforces envelope sender == SASL login, except + * for the null reverse path, which is rejected explicitly below. + */ + +'use strict'; + +const { createUnsubscribeToken } = require('./unsubscribe-token'); + +/** Envelope address normalization: strip <>, ESMTP params, case. */ +function parseAddress(raw) { + const first = String(raw ?? '').trim().split(/\s+/)[0] ?? ''; + const match = /<([^>]*)>/.exec(first); + return (match ? match[1] : first).trim().toLowerCase(); +} + +/** + * RCPT decision: reject recipients who unsubscribed from this sender. A DB + * failure DEFERS (fail closed) — delivering to someone who opted out would + * break the synchronous suppression guarantee, so Postfix must retry. + * + * @returns {Promise<{action: 'continue'|'reject'|'tempfail', message?: string}>} + */ +async function rcptDecision(db, sender, recipient) { + if (!sender) return { action: 'reject', message: '5.7.1 Envelope sender required' }; + if (!recipient) return { action: 'continue' }; + try { + if (await db.isSuppressed(sender, recipient)) { + return { action: 'reject', message: `5.7.1 Recipient has unsubscribed from ${sender}` }; + } + return { action: 'continue' }; + } catch (err) { + return { action: 'tempfail', message: `4.3.0 Suppression lookup unavailable (${err.code ?? 'db error'})` }; + } +} + +/** + * Headers to add at end-of-message. Tokens identify (account, recipient), so + * headers are only added when the transaction has exactly one recipient — + * multi-recipient submissions (rare for app mail) get none. + * + * @returns {Promise<{name: string, value: string}[]>} empty when not applicable + */ +async function unsubscribeHeaders({ db, unsubscribe }, sender, recipients) { + const activeKey = (unsubscribe.keys ?? []).find((k) => k.active); + if (!activeKey || !unsubscribe.baseUrl || recipients.length !== 1) return []; + const account = await db.getSender(sender); + if (!account || !account.unsubscribeHeaders) return []; + const token = createUnsubscribeToken({ accountId: account.accountId, recipient: recipients[0] }, activeKey); + return [ + { name: 'List-Unsubscribe', value: `<${unsubscribe.baseUrl}/u/${token}>` }, + { name: 'List-Unsubscribe-Post', value: 'List-Unsubscribe=One-Click' }, + ]; +} + +/** Header names the helper owns — client-supplied ones are forged and removed. */ +function isUnsubscribeHeader(name) { + return /^list-unsubscribe(-post)?$/i.test(String(name).trim()); +} + +module.exports = { parseAddress, rcptDecision, unsubscribeHeaders, isUnsubscribeHeader }; diff --git a/mail-helper/src/index.js b/mail-helper/src/index.js new file mode 100644 index 00000000..c5227a91 --- /dev/null +++ b/mail-helper/src/index.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * opensource-mail-helper: a Postfix milter (issue #67, PR #495 review). + * + * Runs pre-queue on the submission ports (587/465), listening on a unix + * socket inside the Postfix chroot. Ordered BEFORE OpenDKIM in + * submission_milters so the DKIM signature covers the headers added here. + * + * - RCPT: rejects recipients who one-click-unsubscribed from the + * authenticated sender (mail_suppressions_v) with a synchronous 5.7.1; + * DB outages DEFER (fail closed). + * - End of message: strips client-forged List-Unsubscribe headers and, for + * single-recipient submissions, injects RFC 8058 one-click headers with + * an encrypted per-recipient token. + * + * Config: ./config.json (committed defaults) + ./config.local.json overlay + * (written by the agent from the mail-host snapshot; gitignored). A + * self-managed MTA can run this daemon too — it only needs the DB views and + * the unsubscribe keys. + */ + +'use strict'; + +const { Decision, MilterServer, SMFIF } = require('milter'); +const { loadConfig } = require('./config'); +const { Db } = require('./db'); +const { parseAddress, rcptDecision, unsubscribeHeaders, isUnsubscribeHeader } = require('./filter'); + +function main() { + const config = loadConfig(); + const db = new Db(config.db); + + const server = new MilterServer({ + socketPath: config.socket, + // Group postfix (unit Group=) + 0660 lets the chrooted smtpd connect. + chmod: 0o660, + // We never need the body — decisions use only envelope and headers. + collectBody: false, + actions: SMFIF.ADDHDRS | SMFIF.CHGHDRS, + }); + + // Per-transaction state keyed on the milter context. + const transactions = new WeakMap(); + const stateOf = (ctx) => { + let state = transactions.get(ctx); + if (!state) { + state = { sender: '', recipients: [], forged: {} }; + transactions.set(ctx, state); + } + return state; + }; + + server.on('mail', (from, ctx) => { + transactions.delete(ctx); // new transaction on the same connection + stateOf(ctx).sender = parseAddress(from[0]); + return Decision.continue(); + }); + + server.on('rcpt', async (to, ctx) => { + const state = stateOf(ctx); + const address = parseAddress(to[0]); + const decision = await rcptDecision(db, state.sender, address); + if (decision.action === 'reject') { + ctx.replyCode('550', decision.message); + return Decision.reject(); + } + if (decision.action === 'tempfail') { + ctx.replyCode('451', decision.message); + return Decision.tempfail(); + } + state.recipients.push(address); + return Decision.continue(); + }); + + server.on('headerLine', (name, _value, ctx) => { + // Track client-supplied List-Unsubscribe headers (forgeries) by their + // per-name occurrence index so they can be deleted at end of message. + if (isUnsubscribeHeader(name)) { + const state = stateOf(ctx); + state.forged[name] = (state.forged[name] ?? 0) + 1; + } + return Decision.continue(); + }); + + server.on('bodyEnd', async (_body, ctx) => { + const state = stateOf(ctx); + try { + for (const [name, count] of Object.entries(state.forged)) { + for (let index = count; index >= 1; index--) { + ctx.changeHeader(name, index, ''); // empty value deletes + } + } + for (const header of await unsubscribeHeaders({ db, unsubscribe: config.unsubscribe }, state.sender, state.recipients)) { + ctx.addHeader(header.name, header.value); + } + } catch (err) { + // Header injection is best-effort; suppression enforcement already + // happened at RCPT. Never block delivery here. + console.error(`mail-helper: header injection failed: ${err.message}`); + } finally { + transactions.delete(ctx); + } + return Decision.accept(); + }); + + server.on('abort', (ctx) => transactions.delete(ctx)); + server.on('close', (ctx) => transactions.delete(ctx)); + server.on('error', (error, ctx) => { + console.error('mail-helper: milter error', ctx?.id, error); + }); + + return server.listen().then(() => { + console.log(`mail-helper: milter listening on ${config.socket}`); + }); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err.stack ?? String(err)); + process.exit(1); + }); +} diff --git a/mail-helper/test/filter.test.js b/mail-helper/test/filter.test.js new file mode 100644 index 00000000..c2036d20 --- /dev/null +++ b/mail-helper/test/filter.test.js @@ -0,0 +1,80 @@ +/** + * Filter decision tests (node --test). Run `make deps` first — it copies the + * canonical unsubscribe-token module from create-a-container. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); + +const { parseAddress, rcptDecision, unsubscribeHeaders, isUnsubscribeHeader } = require('../src/filter'); + +const key = { kid: 'a1b2c3d4', secret: crypto.randomBytes(32).toString('base64'), active: true }; +const unsubscribe = { baseUrl: 'https://manager.example.com', keys: [key] }; + +const db = { + async getSender(address) { + if (address === 'app@example.com') return { accountId: 'acc-1', unsubscribeHeaders: true }; + if (address === 'quiet@example.com') return { accountId: 'acc-2', unsubscribeHeaders: false }; + return null; + }, + async isSuppressed(sender, recipient) { + return sender === 'app@example.com' && recipient === 'optedout@example.net'; + }, +}; + +test('parseAddress normalizes envelope forms', () => { + assert.equal(parseAddress(''), 'app@example.com'); + assert.equal(parseAddress('app@example.com SIZE=1024'), 'app@example.com'); + assert.equal(parseAddress('<>'), ''); +}); + +test('rcptDecision rejects suppressed recipients with 5.7.1', async () => { + const hit = await rcptDecision(db, 'app@example.com', 'optedout@example.net'); + assert.equal(hit.action, 'reject'); + assert.match(hit.message, /^5\.7\.1 /); + + const miss = await rcptDecision(db, 'app@example.com', 'someone@example.net'); + assert.equal(miss.action, 'continue'); +}); + +test('rcptDecision rejects a null envelope sender', async () => { + const decision = await rcptDecision(db, '', 'optedout@example.net'); + assert.equal(decision.action, 'reject'); + assert.match(decision.message, /^5\.7\.1 /); +}); + +test('rcptDecision DEFERS on DB failure (fail closed)', async () => { + const broken = { async isSuppressed() { throw Object.assign(new Error('down'), { code: 'ECONNREFUSED' }); } }; + const decision = await rcptDecision(broken, 'app@example.com', 'x@y.z'); + assert.equal(decision.action, 'tempfail'); + assert.match(decision.message, /^4\.3\.0 /); +}); + +test('unsubscribeHeaders adds tokenized headers for one opted-in recipient', async () => { + const headers = await unsubscribeHeaders({ db, unsubscribe }, 'app@example.com', ['someone@example.net']); + assert.equal(headers.length, 2); + assert.match(headers[0].value, /^$/); + assert.equal(headers[1].value, 'List-Unsubscribe=One-Click'); +}); + +test('unsubscribeHeaders is skipped when not applicable', async () => { + const cases = [ + [{ db, unsubscribe }, 'quiet@example.com', ['r@x.y']], // account opted out + [{ db, unsubscribe }, 'ghost@example.com', ['r@x.y']], // not a managed sender + [{ db, unsubscribe }, 'app@example.com', ['a@x.y', 'b@x.y']], // tokens are per-recipient + [{ db, unsubscribe: { baseUrl: null, keys: [key] } }, 'app@example.com', ['r@x.y']], + [{ db, unsubscribe: { baseUrl: 'https://m', keys: [] } }, 'app@example.com', ['r@x.y']], + ]; + for (const [deps, sender, recipients] of cases) { + assert.deepEqual(await unsubscribeHeaders(deps, sender, recipients), []); + } +}); + +test('isUnsubscribeHeader matches both owned header names, case-insensitive', () => { + assert.ok(isUnsubscribeHeader('List-Unsubscribe')); + assert.ok(isUnsubscribeHeader('list-unsubscribe-post')); + assert.ok(!isUnsubscribeHeader('List-Id')); +});