Skip to content
Merged
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 Makefile
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 9 additions & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 28 additions & 2 deletions images/manager/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
1 change: 1 addition & 0 deletions images/manager/container-creator-init.service
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand Down
4 changes: 2 additions & 2 deletions images/proxmox-ve/interfaces
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If its not in another PR, we need to make sure these ports are documented for administrators to forward to the agent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are documented in #505 (the docs PR of this series): docs/admins/installation.md gains a "Mail Ports and PTR" section — forward TCP 25 (SMTP), 587/465 (submission), 993/143 (IMAP) to the manager container, plus the PTR requirement — and docs/admins/email.md covers repointing these forwards when the mail host moves to another agent.

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
11 changes: 11 additions & 0 deletions mail-helper/.fpm
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--name opensource-mail-helper
--architecture all
--license Apache-2.0
--maintainer "Medical Informatics Engineering <helpdesk@mieweb.com>"
--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
7 changes: 7 additions & 0 deletions mail-helper/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
config.local.json
src/unsubscribe-token.js
.pkg/
*.deb
*.rpm
*.apk
73 changes: 73 additions & 0 deletions mail-helper/Makefile
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions mail-helper/README.md
Original file line number Diff line number Diff line change
@@ -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`.
15 changes: 15 additions & 0 deletions mail-helper/config.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
19 changes: 19 additions & 0 deletions mail-helper/contrib/postinstall.sh
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions mail-helper/contrib/systemd/opensource-mail-helper.service
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading