You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This pull request introduces support for managing mail-related services and dependencies in the agent. It adds new mail service management capabilities, ensures required binaries are tracked for each service group, and updates the package dependencies to support PostgreSQL and MySQL integration. Additionally, it improves the configuration management system to handle file ownership and post-write commands for managed services.
Mail service management and integration:
Added a new systemd unit file opensource-mail-helper.service for managing mail helper processes, and updated the build/install process to include this service. [1][2]
Updated .fpm packaging to suggest mail-related packages (postfix, dovecot, opendkim, and their database backends) for Debian installations.
Dependency and database support:
Added mysql2 and pg (PostgreSQL) as runtime dependencies in both package.json and package-lock.json, along with their transitive dependencies, to enable integration with MySQL and PostgreSQL databases. [1][2][3][4][5][6][7][8][9][10]
Configuration and file management improvements:
Enhanced the ManagedService interface and related logic in apply.ts to support service groups, required binaries, file ownership, and post-write commands, allowing for more robust and secure configuration management. [1][2][3][4][5][6]
New tables DkimKeys, MailAccounts, MailSuppressions, MailUnsubscribeKeys;
mail enablement/verification columns on ExternalDomains; agent service
reporting + single-mail-host claim columns on Agents (partial unique index
on Postgres/SQLite). mail_accounts_v / mail_senders_v / mail_suppressions_v
views are the stable contract Dovecot and Postfix read live;
bin/setup-mail-db-roles.sh creates the read-only mail_dovecot/mail_postfix
DB users and grants.
MVC-manifesto vertical slice: list/create/get/patch/rotate-password/delete
plus GET /mail-accounts/domains (can-send domains) and the suppressions
sub-resource (owner-readable, admin-removable). Passwords are 256-bit
random, shown once, stored as Argon2id p=1 (Dovecot/libsodium compatible).
Reserved role local parts are admin-only; quota and ownership transfer are
admin-only; accounts can only be created on domains that can send.
Every external domain gets an RSA-2048 DKIM key (at create + backfill
migration). Admin endpoints on /external-domains/{id}/mail: dns (records
to publish + last check), check-dns, enable (422 dns_not_configured until
SPF/DKIM/DMARC verify; MX only gates receiving), disable (keeps keys).
Check DNS runs against configurable public resolvers; PTR and missing
postmaster@/abuse@ are warnings. Mail settings keys added to /settings
(relayhost requires mail_spf_include).
Agents report enabledServices + missingBinaries at check-in; the first
agent reporting 'mail' with everything installed takes the single mail-host
claim (conflict for others, unsupported on SQLite, missing_packages when
binaries are absent; turning mail off releases it). Only the holder
receives config.mail: DKIM private keys for can-send domains, receiving
domains, mail_hostname, relayhost, limits, and DB connection (no DB
passwords - those stay in /etc/default/container-creator). Remote agents
are pinned to their first API key (DELETE /agents/:id/api-key-pin to
clear). New admin router /api/v1/mail: GET /host, POST /host/release,
GET /ptr.
)
AES-256-GCM tokens over {v, kid, accountId, recipient, iat} (kid bound as
AAD, base64url): unforgeable, reveal nothing, and any failure mode answers
the same generic 400. Public router at /u (outside /api/v1, before the
CSRF guard, per-IP rate limited): GET renders a confirm page and never
unsubscribes (prefetch-safe); POST requires the RFC 8058
List-Unsubscribe=One-Click body and idempotently upserts the suppression.
MailUnsubscribeKey.ensureActive()/verifiable() manage the single active
key; retired keys verify for one more year. The mail snapshot now carries
the keys + mail_unsubscribe_base_url to the claim holder so
opensource-mail-helper can mint the same tokens.
Mail tag + MailAccount/MailAccountSecret/MailDnsRecord/MailSuppression
schemas; paths for /mail-accounts (+domains, rotate-password,
suppressions), /external-domains/{id}/mail/*, /mail/host|ptr, and
/agents/{id}/api-key-pin. Agent check-in documents enabledServices,
missingBinaries, mailStatus, and the holder-only mail snapshot. The MCP
server picks these up automatically. Public /u/{token} is outside /api/v1
and intentionally not in this spec.
Service groups nginx, dnsmasq, mail - unset AGENT_SERVICES means
nginx,dnsmasq, unknown names are rejected at startup. The agent only
renders/applies and reports unit status for its enabled groups, and every
check-in now carries enabledServices plus missingBinaries (PATH + sbin
scan of each group's required binaries; the manager refuses the mail-host
claim while anything is missing).
The claim holder renders the full mail stack from the snapshot: Postfix
(submission 587/465 with TLS-only AUTH, sender==login, suppression policy
at RCPT, after-queue content filter; :25 only accepts can-receive domains
gated by Dovecot quota-status; SNI certs shared with nginx, compiled via
postmap -F postWrite), Dovecot 2.4 (live SQL passdb/userdb against
mail_accounts_v - submission requires can_send, IMAP stays readable;
LMTP/auth/quota-status sockets; doveadm auth cache flush on reload),
OpenDKIM (keys under /etc/dkimkeys, milter on the reinjection listener
only, oversigns List-Unsubscribe per RFC 8058), and
opensource-mail-helper - a small Node daemon (content filter that strips
forged List-Unsubscribe headers and injects tokenized one-click headers
per recipient, plus the suppression policy service; fail-open on DB
errors). apply.ts gains file owner, postWrite, and statusUnit (postfix@-);
system.ts gains enable/disable+start/stop. Turning mail off or losing the
claim stops and disables the units, keeping configs and /var/vmail.
Mailbox GC removes Maildirs of deleted accounts after 30 days using
mailboxIds now carried in the snapshot; mail_senders_v gains
account_id + unsubscribe_headers for the helper.
opensource-agent.deb suggests the mail packages (postfix(+sql), dovecot
core/imapd/lmtpd(+sql), opendkim) and ships opensource-mail-helper.service.
The manager image preseeds Postfix 'No configuration', installs the pgsql
variants, keeps the units disabled until the agent claims, sets
AGENT_SERVICES=nginx,dnsmasq,mail, maps postfix/dovecot/opensource-mail OS
users onto the read-only mail_* DB roles via pg_hba/pg_ident peer auth,
and EXPOSEs 25/143/465/587/993. setup-mail-db-roles.sh runs from
container-creator-init.service and the server package postinst; roles
script and views migration each grant best-effort so either ordering
converges.
Publish and DNAT the mail ports (25/143/465/587/993) to the manager CT
like 80/443; bootstrap-manager points the relayhost at MailDev (with the
required mailSpfInclude placeholder) so submitted mail lands in
http://localhost:1080; Check DNS gets a non-production-only bypass for
localhost / *.localhost domains so the whole flow is testable in the dev
stack.
/mail-accounts page for all users, modeled on API keys: inline create form
(can-send domain picker; helpful empty state when no domain can send),
one-time password + connection-settings alert with copy button, enable/
disable, quota display, per-account RFC 8058 unsubscribe-headers toggle,
rotate-password (re-shows credentials once), delete, and a suppressions
modal (admins can remove entries). The public unsubscribe page itself is
the server-rendered /u/{token} confirm page from the backend commit -
deliberately outside the authenticated SPA.
External-domain edit page gains a Mail section: DNS records to publish
with copy buttons, Check DNS, enable/disable, send/receive badges with
reasons, per-gate last-check badges + warnings, and a notice when nothing
runs the mail service. Agents page shows a Mail column (Mail host /
Standby / Missing packages badges) plus release-claim and clear-key-pin
actions. Settings gains a Mail service section for all mail_* settings
with a 'Suggest from PTR' helper and relayhost=>SPF-include validation.
New admins/email.md (mail host claim, settings, where mail runs A/B/C,
DB roles, backups) and users/email-accounts.md (client settings,
unsubscribe headers + suppressions behavior, limits). External-domains
doc gains the Mail enablement section; deploying-agents and the agent
developer doc cover AGENT_SERVICES, the mail service group, and the
MAIL_*_DB_PASSWORD env vars; installation.md documents the mail ports and
the PTR requirement; database-schema.md adds the mail tables to the ER
diagram plus the mail-views contract table. Nav updated.
Adds a manager-hosted email platform with SMTP submission, IMAP, DKIM, DNS verification, account management, suppressions, and agent-controlled mail services.
Changes:
Adds mail models, migrations, SQL views, APIs, UI, and documentation.
Adds Postfix, Dovecot, OpenDKIM, and unsubscribe-helper agent management.
Updates packaging, networking, deployment, and tests.
1. Set **Self-managed site id** to the site whose `externalIp` sends mail.
2. Point Dovecot at `mail_accounts_v` and Postfix at `mail_senders_v`, with Dovecot's `quota-status` guarding RCPT. The views are a documented, stable contract — see [Database Schema](../developers/database-schema.md#mail-views).
3. Export DKIM keys per domain (`GET /api/v1/external-domains/{id}/mail/dkim` is logged) and use the agent templates under `agent/templates/mail/` as a reference. Run `opensource-mail-helper` yourself for unsubscribe headers, or skip them.
The reason will be displayed to describe this comment to others. Learn more.
Implemented in ea39ace GET /external-domains/{id}/mail/dkim (admin-only) returns the active key pair, writes an audit line to the manager log, and is documented in the OpenAPI spec. The self-managed (option C) workflow now works as documented.
The reason will be displayed to describe this comment to others. Learn more.
I'd like this mail helper to get it's own component rather than being hidden in the agent package. For reference, this means it's own directory, Makefile, .fpm config, package name, and ensuring it's built by the root Makefile for the publishing pipeline. It should then be suggested by the opensource-agent package. It would be installed into /opt/opensource-server/mail-helper instead of under the agent's prefix.
The reason will be displayed to describe this comment to others. Learn more.
Done in ff9da06 new top-level mail-helper with its own Makefile, .fpm, package name opensource-mail-helper, built by the root Makefile (so the builder image / publishing pipeline picks it up), installed to /opt/opensource-server/mail-helper, and suggested by opensource-agent. The manager image installs the deb; the compose node service builds it for the dev stack.
The reason will be displayed to describe this comment to others. Learn more.
ff9da06 the milter rewrite (see #19) eliminated the content filter, reinjection listener, and policy service entirely, so zero localhost ports remain. The helper listens on /var/spool/postfix/mail-helper/milter.sock (unit runs Group=postfix, socket 0660).
The reason will be displayed to describe this comment to others. Learn more.
All config up until now has been via environment variables sourced from /etc/default/opensource-<component>. Is there a compelling reason for this to be a JSON file rather than an environment file?
If there is I want a different split. The package should install /opt/opensource-server/mail-helper/config.json which is NOT a conffile in dpkg and is committed to the repo. It would instead accept overlays from ./config.local.json which is also .gitignored so config can work the same in local development as well as production.
The reason will be displayed to describe this comment to others. Learn more.
Implemented the described split in ff9da06. As for the why the compelling reason for JSON is the structured unsubscribe key array (kid/secret/active, rotating), which doesn't map to env vars. The package ships a committed config.json (not a dpkg conffile); the agent writes the gitignored ./config.local.json overlay, so dev and production work identically.
The reason will be displayed to describe this comment to others. Learn more.
I'm a little concerned with this (and to the other) mail header manipulation. Is there an existing well-tested NPM library that can be used to manipulate these rather than rolling our own?
The reason will be displayed to describe this comment to others. Learn more.
helper no longer manipulates raw messages at all. It's a milter built on the milter npm package (Milter.js), so header changes are protocol actions (addHeader/changeHeader) applied by Postfix itself, and the hand-rolled SMTP/dot-stuffing code is gone.
The reason will be displayed to describe this comment to others. Learn more.
Same as above. I don't mind using node's net.createServer if nessecary, but if there's an "express"-like wrapper purpose built for milters that would be great to use instead.
The reason will be displayed to describe this comment to others. Learn more.
Adopted in ff9da06 rebuilt on Milter.js exactly as suggested (v3.0.0; API verified against its typings). One milter on the submission ports now does both jobs: suppression rejection at RCPT and List-Unsubscribe injection at EOM, with OpenDKIM running as the next milter in submission_milters so the signature covers the added headers. One trade-off to flag: milters can't split a message per recipient the way the old content filter (mailhelper_destination_recipient_limit=1) did, so tokenized unsubscribe headers are only added to single-recipient submissions — multi-RCPT mail (rare for app traffic) gets none. Shout if that's a dealbreaker and I'll revisit.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ff9da06 the multi-value submission policy now lives in main.cf as named parameters (submission_recipient_restrictions, submission_milters) and master.cf overrides reference them as $name, so every -o is single-valued. (The { } form is valid Postfix ≥3.0 syntax, but agreed it's obscure — gone.)
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why this is a script rather than a migration. Can we convert it to be a migration to leverage Sequelize's dialect handling instead of inventing our own in bash?
The reason will be displayed to describe this comment to others. Learn more.
ea39ace the bash script is replaced by migration 20261012000000-create-mail-db-roles.js using Sequelize's dialect handling. container-creator-init.service grants the app user CREATEROLE so it works on the manager; where the app user lacks the privilege the migration logs the SQL for a DBA and continues. Local hosts use socket auth (pg_ident peer map) so the roles get no usable password (MySQL gets a throwaway); remote mail hosts set one via ALTER ROLE … WITH PASSWORD.
- settings: strip trailing slashes without regex (CodeQL polynomial ReDoS)
- Check DNS: DMARC requires exactly one record with a valid p= tag;
multiple records warn (permerror)
- mail IP: self-managed fallback applies only when NO agent holds the
claim, so checks can't verify against an unrelated IP
- claim: a holder reporting missing binaries releases the claim instead
of wedging every other agent in conflict
- local part: model + API validators reject consecutive dots (a..b)
- domain create: ExternalDomain + DkimKey in one transaction
- new admin GET /external-domains/{id}/mail/dkim export (audited) so the
documented self-managed workflow exists
- agents: the secret-bearing mail snapshot is only sent to local or TLS
check-ins; plaintext remote holders get mailStatus=insecure_transport
- nginx: the snapshot carries the unsubscribe host and the template adds
an auth_request-exempt /u/ location on that server, so oauth2-proxy
can't block RFC 8058 one-click POSTs
- in-app rate limiting removed per review (proxy concern, out of scope)
- setup-mail-db-roles.sh replaced by migration 20261012000000 using
Sequelize's dialect handling; init service grants CREATEROLE; remote
hosts get passwords via ALTER ROLE (docs updated)
- new mail_recipients_v view backs a live Postfix recipient map (no more
static:x backscatter; granted to mail_postfix)
New top-level mail-helper/ component (own Makefile, .fpm, package name,
built by the root Makefile, installed to /opt/opensource-server/mail-helper,
suggested by opensource-agent) replacing the hand-rolled SMTP content
filter + policy service hidden in the agent package. It is now a Postfix
milter built on the well-tested 'milter' npm library, listening on a unix
socket inside the Postfix chroot - no localhost ports, no reinjection
listener, no master.cf '-o { }' overrides (submission policy lives in
main.cf as named parameters):
- RCPT: suppressed recipients rejected 5.7.1; DB outages DEFER
(fail closed - no more delivering to opted-out recipients)
- EOM: forged List-Unsubscribe headers stripped, tokenized RFC 8058
headers added (single-recipient submissions; tokens are per-recipient),
then OpenDKIM - also moved to a chroot unix socket - signs them
- unsubscribe-token.js is copied from the manager at build time
(gitignored) so the two implementations stay in sync by construction
- config: committed config.json + agent-written config.local.json
overlay (same split in dev and production); paths env-overridable
- vmail/opensource-mail users come from the package postinst; the agent
only manages directories, with DKIM keys and OpenDKIM tables under
\/mail instead of /etc
Agent: apply.ts reconciles file mode/owner even when contents are
unchanged and re-runs postWrite commands after rollback (no stale
sni_map.db); removing 'mail' from AGENT_SERVICES now stops and disables
a running mail stack.
Dev-stack testing caught stopMailUnitsIfRunning disabling a host's stock
postfix that the agent never managed. state.json now tracks mailManaged
(set when the holder enables the units, cleared on shutdown), and both
shutdown paths - losing the claim and removing 'mail' from AGENT_SERVICES
- are gated on it, so an unrelated postfix/dovecot on the host is never
touched.
The unsubscribe base is stored without validating its scheme or path. Values such as http://host or https://host/prefix are accepted, but RFC 8058 requires an HTTPS URI and the helper appends /u/<token> while Express only mounts /u, producing insecure or unusable one-click links. Require an absolute HTTPS origin and persist its normalized origin.
SPF validation accepts domains with multiple records
create-a-container/utils/mail-dns.js:179
This selects the first SPF record and can mark the domain verified even when multiple SPF records are published. Multiple SPF records are a PermError for receivers, so the send gate may open for a domain whose SPF is invalid. Require exactly one SPF record, as the DMARC check below already does.
The reason will be displayed to describe this comment to others. Learn more.
f9b7766 the chown shell-outs are replaced with fs.chownSync. Node has no getpwnam/getgrnam binding, so a small chownByName() in system.ts resolves user/group names from /etc/passwd and /etc/group first (all the mail users are local system users from the package postinsts, so that's reliable).
The reason will be displayed to describe this comment to others. Learn more.
This scares me because it's a stateful change that's hard to track and rollback. I'm also not sure it's relevant as long as we start opendkim in the Postfix group which is more easily controllable via the opendkim.conf
The reason will be displayed to describe this comment to others. Learn more.
the mail_unsubscribe_base_url setting is deleted everywhere (settings API, Settings page, OpenAPI, dev bootstrap). The helper's unsubscribe.baseUrl is now the mail-host agent's own MANAGER_URL written into config.local.json by the agent and the snapshot only carries the keys.
Derive one-click unsubscribe URLs from the agent's MANAGER_URL and remove the separate setting and nginx auth bypass. Also simplify file ownership handling, configure OpenDKIM to use the postfix group, and make mail paths and GC settings configurable through environment variables.
Mailbox garbage collection skips runs after unchanged snapshots
agent/src/index.ts:78
Mailbox GC only runs after a changed configuration response. Deleting an account causes one new snapshot and records its first-missing timestamp, but subsequent check-ins return 304 and exit before this call. If configuration stays stable, the mailbox is never removed after the documented 30 days. Run retention processing on every timer invocation using persisted mailbox IDs, independently of whether the snapshot changed.
This HTML-escaped interpolation changes valid DB passwords containing characters such as & or apostrophes. Postfix does not decode HTML entities in SQL map configuration, so the mailbox lookup authenticates with the wrong password and inbound recipient checks fail. Use non-HTML interpolation for this configuration value.
EJS's <%= operator HTML-escapes this password. A configured MAIL_POSTFIX_DB_PASSWORD such as p&ss is written as p&ss, which Postfix sends literally to the database, so sender-ownership lookups fail with otherwise valid credentials. Render the password without HTML escaping in this non-HTML configuration.
Run mailbox GC even when check-ins return unchanged snapshots
agent/src/index.ts:78
[P2] Mailbox GC is only reached when check-in returns a new snapshot. After deleting an account, gcMailboxes records its first-missing time, but subsequent unchanged check-ins return at the 304 branch before reaching this call. If no further configuration changes occur, the mailbox is never removed after the 30-day retention period. Schedule GC independently of snapshot changes, retaining the last authoritative mailbox list for unchanged check-ins.
Preserve Dovecot database passwords without HTML escaping
agent/templates/mail/dovecot.conf.ejs:38
[P2] <%= HTML-escapes the Dovecot database password. A configured password containing '&', quotes, or angle brackets is changed into HTML entities before Dovecot uses it, breaking SMTP/IMAP authentication for those database credentials. Render the original credential with Dovecot-appropriate quoting/escaping rather than HTML escaping.
Preserve Postfix SQL map passwords without HTML escaping
[P2] This HTML-escapes the database password even though the output is a Postfix SQL map, not HTML. A valid password such as 'a&b' becomes 'a&b', causing recipient lookups to fail and inbound mail to be deferred. Preserve the configured credential when rendering this map.
[P2] EJS's <%= directive HTML-escapes this database password. For example, a configured password containing '&' is written as '&', which Postfix sends literally to the database. Remote sender-ownership lookups then fail despite correct credentials. Emit the password as Postfix configuration text rather than HTML.
Reject domains with multiple SPF records
create-a-container/utils/mail-dns.js:175
[P2] Reject multiple SPF records instead of selecting the first one. A domain publishing both 'v=spf1 ip4:203.0.113.9 -all' and 'v=spf1 -all' currently passes this check and the can-send gate, although SPF receivers treat multiple SPF records as a permanent error. This also occurs when an administrator adds the suggested SPF record alongside an existing provider's record. Require exactly one SPF record before evaluating it, as the DMARC check already does.
The Dovecot database password is rendered through EJS's HTML-escaping output tag. Valid passwords containing &, quotes, or angle brackets consequently change value in the generated config and prevent SMTP/IMAP authentication against a remote database. Use Dovecot-compatible quoting/escaping and unescaped EJS output instead of HTML encoding.
This password is HTML-escaped by <%= as well. For example, a database password containing & is rendered with &, so recipient-map authentication fails even though the configured environment password is correct. Render the Postfix password without HTML entity encoding.
Emit literal Postfix passwords with config-safe escaping
EJS's <%= output is HTML-escaped, but this is a Postfix configuration file. A valid MAIL_POSTFIX_DB_PASSWORD containing &, <, or an apostrophe is written as an HTML entity and no longer matches the database password, breaking sender-login lookups on password-authenticated deployments. Emit the literal value using the configuration format's escaping rather than HTML escaping.
Reject domains with multiple SPF records
create-a-container/utils/mail-dns.js:175
Selecting the first SPF record allows a domain with multiple v=spf1 TXT records to pass verification. A self-contained check with two SPF records, a matching DKIM key, and valid DMARC returns spf.pass=true and enables the send gate, although receivers treat multiple SPF records as a permanent error. Require exactly one SPF record, as the DMARC check already does, before evaluating its mechanisms.
The reason will be displayed to describe this comment to others. Learn more.
I don't want a change for this in the PR, but I'd like a TODO comment added here to replace these options with something integrated to the new email system, like a "noreply account" dropdown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves: #67
This pull request introduces support for managing mail-related services and dependencies in the agent. It adds new mail service management capabilities, ensures required binaries are tracked for each service group, and updates the package dependencies to support PostgreSQL and MySQL integration. Additionally, it improves the configuration management system to handle file ownership and post-write commands for managed services.
Mail service management and integration:
opensource-mail-helper.servicefor managing mail helper processes, and updated the build/install process to include this service. [1] [2].fpmpackaging to suggest mail-related packages (postfix,dovecot,opendkim, and their database backends) for Debian installations.Dependency and database support:
mysql2andpg(PostgreSQL) as runtime dependencies in bothpackage.jsonandpackage-lock.json, along with their transitive dependencies, to enable integration with MySQL and PostgreSQL databases. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]Configuration and file management improvements:
ManagedServiceinterface and related logic inapply.tsto support service groups, required binaries, file ownership, and post-write commands, allowing for more robust and secure configuration management. [1] [2] [3] [4] [5] [6]Screenshots




