Skip to content

Email Service Hosted on the Manager - #495

Open
cmyers-mieweb wants to merge 19 commits into
mainfrom
cmyers_email-service
Open

cmyers-mieweb wants to merge 19 commits into
mainfrom
cmyers_email-service

Conversation

@cmyers-mieweb

@cmyers-mieweb cmyers-mieweb commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • 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]

Screenshots
image
image
image
image
image

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.
@cmyers-mieweb
cmyers-mieweb requested review from runleveldev and a balanced review from Copilot October 8, 2026 22:54
@cmyers-mieweb cmyers-mieweb changed the title Cmyers email service Email Service Hosted on the Manager Oct 8, 2026
Comment thread create-a-container/routers/api/v1/settings.js Fixed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Mail-host lifecycle, secret transport, recipient validation, rollback, and suppression enforcement contain blocking correctness and security issues.

14 open findings
What changed in this PR

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.
File Description
mie-opensource-landing/​zensical.toml Adds mail documentation navigation.
mie-opensource-landing/​docs/​users/​email-accounts.md Documents user mail accounts.
mie-opensource-landing/​docs/​developers/​database-schema.md Documents mail schema and views.
mie-opensource-landing/​docs/​developers/​agent.md Documents mail service management.
mie-opensource-landing/​docs/​admins/​installation.md Documents mail ports and PTR.
mie-opensource-landing/​docs/​admins/​email.md Adds mail administration guide.
mie-opensource-landing/​docs/​admins/​deploying-agents.md Documents agent mail configuration.
mie-opensource-landing/​docs/​admins/​core-concepts/​external-domains.md Documents domain mail enablement.
images/​proxmox-ve/​interfaces Forwards mail ports.
images/​manager/​Dockerfile Installs and exposes mail services.
images/​manager/​container-creator-init.service Initializes mail database roles.
create-a-container/​openapi.v1.yaml Documents mail APIs and schemas.
create-a-container/​utils/​unsubscribe-token.js Implements unsubscribe tokens.
create-a-container/​utils/​mail-views.js Generates mail SQL views.
create-a-container/​utils/​mail-host.js Manages the mail-host claim.
create-a-container/​utils/​mail-dns.js Builds and verifies mail DNS.
create-a-container/​utils/​mail-account.js Provides account credential helpers.
create-a-container/​utils/​dkim.js Generates DKIM keys.
create-a-container/​utils/​agent-config.js Builds holder-only mail snapshots.
create-a-container/​utils/​__tests__/​unsubscribe-token.test.js Tests unsubscribe tokens.
create-a-container/​utils/​__tests__/​mail-dns.test.js Tests DNS verification.
create-a-container/​routers/​unsubscribe.js Adds public unsubscribe routes.
create-a-container/​routers/​api/​v1/​settings.js Adds mail settings.
create-a-container/​routers/​api/​v1/​mail.js Adds mail-host administration.
create-a-container/​routers/​api/​v1/​index.js Mounts mail APIs.
create-a-container/​routers/​api/​v1/​external-domains.js Adds domain mail controls.
create-a-container/​routers/​api/​v1/​agents.js Adds mail claims and key pinning.
create-a-container/​resources/​mailaccounts/​validator.js Validates mail-account requests.
create-a-container/​resources/​mailaccounts/​service.js Implements account business logic.
create-a-container/​resources/​mailaccounts/​serializer.js Serializes mail resources.
create-a-container/​resources/​mailaccounts/​router.js Defines account routes.
create-a-container/​resources/​mailaccounts/​repository.js Implements account persistence.
create-a-container/​resources/​mailaccounts/​controller.js Handles account API requests.
create-a-container/​resources/​mailaccounts/​__tests__/​service.test.js Tests account service rules.
create-a-container/​models/​mail-unsubscribe-key.js Models unsubscribe keys.
create-a-container/​models/​mail-suppression.js Models suppressions.
create-a-container/​models/​mail-account.js Models mail accounts.
create-a-container/​models/​external-domain.js Adds domain mail state.
create-a-container/​models/​dkim-key.js Models DKIM keys.
create-a-container/​models/​agent.js Adds agent mail-claim fields.
create-a-container/​migrations/​20261008000007-backfill-dkim-keys.js Backfills DKIM keys.
create-a-container/​migrations/​20261008000006-create-mail-views.js Creates mail views.
create-a-container/​migrations/​20261008000005-create-mail-unsubscribe-keys.js Creates unsubscribe-key storage.
create-a-container/​migrations/​20261008000004-create-mail-suppressions.js Creates suppression storage.
create-a-container/​migrations/​20261008000003-add-agent-mail-fields.js Adds agent mail fields.
create-a-container/​migrations/​20261008000002-create-mail-accounts.js Creates mail accounts.
create-a-container/​migrations/​20261008000001-create-dkim-keys.js Creates DKIM keys.
create-a-container/​migrations/​20261008000000-add-external-domain-mail-columns.js Adds domain mail columns.
create-a-container/​middlewares/​rate-limit.js Adds unsubscribe rate limiting.
create-a-container/​contrib/​postinstall.sh Provisions mail DB roles.
create-a-container/​client/​src/​pages/​settings/​SettingsPage.tsx Adds mail settings UI.
create-a-container/​client/​src/​pages/​mail-accounts/​SuppressionsModal.tsx Displays suppressions.
create-a-container/​client/​src/​pages/​mail-accounts/​NewMailAccountForm.tsx Creates mail accounts.
create-a-container/​client/​src/​pages/​mail-accounts/​MailCredentialsAlert.tsx Displays one-time credentials.
create-a-container/​client/​src/​pages/​mail-accounts/​MailAccountsListPage.tsx Manages mail accounts.
create-a-container/​client/​src/​pages/​external-domains/​ExternalDomainFormPage.tsx Embeds domain mail controls.
create-a-container/​client/​src/​pages/​external-domains/​DomainMailSection.tsx Manages domain DNS and enablement.
create-a-container/​client/​src/​pages/​agents/​MailHostBadge.tsx Displays mail-host state.
create-a-container/​client/​src/​pages/​agents/​AgentsListPage.tsx Adds mail state and actions.
create-a-container/​client/​src/​pages/​agents/​AgentRowActions.tsx Adds release and pin actions.
create-a-container/​client/​src/​lib/​types.ts Adds mail client types.
create-a-container/​client/​src/​lib/​queries.ts Adds mail API queries.
create-a-container/​client/​src/​app/​Sidebar.tsx Adds Email navigation.
create-a-container/​client/​src/​app/​router.tsx Adds the mail-account route.
create-a-container/​bin/​setup-mail-db-roles.sh Creates read-only mail DB users.
create-a-container/​app.js Mounts public unsubscribe handling.
compose.yml Exposes and configures development mail.
agent/​test/​mail-template.test.js Tests mail templates.
agent/​test/​mail-helper.test.js Tests unsubscribe header handling.
agent/​test/​config.test.js Tests service-group parsing.
agent/​templates/​mail/​postfix-vdomains.ejs Renders receiving domains.
agent/​templates/​mail/​postfix-sql-senders.cf.ejs Configures sender lookup.
agent/​templates/​mail/​postfix-sni-map.ejs Configures TLS SNI mapping.
agent/​templates/​mail/​postfix-master.cf.ejs Configures SMTP services.
agent/​templates/​mail/​postfix-main.cf.ejs Configures Postfix behavior.
agent/​templates/​mail/​opendkim.conf.ejs Configures OpenDKIM.
agent/​templates/​mail/​opendkim-signingtable.ejs Maps signing domains.
agent/​templates/​mail/​opendkim-keytable.ejs Maps DKIM keys.
agent/​templates/​mail/​dovecot.conf.ejs Configures IMAP, auth, and quotas.
agent/​src/​unsubscribe-token.ts Mints compatible unsubscribe tokens.
agent/​src/​mail-helper.ts Implements filtering and suppression policy.
agent/​src/​types.ts Adds mail snapshot types.
agent/​src/​system.ts Adds binary and unit management.
agent/​src/​mail.ts Implements mail service lifecycle.
agent/​src/​index.ts Integrates mail into check-ins.
agent/​src/​config.ts Parses service groups.
agent/​src/​apply.ts Adds ownership and post-write support.
agent/​package.json Adds database drivers.
agent/​package-lock.json Locks database dependencies.
agent/​Makefile Installs the helper unit.
agent/​contrib/​systemd/​opensource-mail-helper.service Runs the mail helper.
agent/​.fpm Suggests mail packages.
Files not reviewed (1)
  • agent/package-lock.json: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread agent/src/apply.ts
Comment thread agent/src/index.ts Outdated
Comment thread agent/src/mail-helper.ts Outdated
Comment thread agent/templates/mail/postfix-main.cf.ejs Outdated
Comment thread create-a-container/routers/api/v1/agents.js Outdated
Comment thread create-a-container/resources/mailaccounts/validator.js Outdated
Comment thread create-a-container/routers/api/v1/external-domains.js Outdated
Comment thread create-a-container/utils/mail-dns.js Outdated
Comment thread create-a-container/utils/mail-host.js
Comment on lines +41 to +43
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.

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.

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.

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.

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.

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.

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.

Comment thread agent/src/mail-helper.ts Outdated
* submission policy service (issue #67). Shipped in the agent package and
* managed as part of the `mail` service group.
*
* - Content filter (127.0.0.1:10025): Postfix pipes submitted mail here one

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.

Can we do the communication on Unix domain sockets in the postfix chroot rather than inet sockets to avoid using more ports than nessecary?

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.

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).

Comment thread agent/src/mail-helper.ts Outdated
import { createUnsubscribeToken, type UnsubscribeKey } from './unsubscribe-token';
import { log } from './log';

const CONFIG_FILE = process.env.MAIL_HELPER_CONFIG || '/etc/opensource-server/mail-helper.json';

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.

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.

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.

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.

Comment thread agent/src/mail-helper.ts Outdated

/** Strip client-supplied List-Unsubscribe(-Post) headers (including folded
* continuation lines) so apps can't forge unsubscribe targets. */
export function stripUnsubscribeHeaders(headerBlock: string): string {

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.

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?

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.

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.

Comment thread agent/src/mail-helper.ts Outdated
return addUnsubscribeHeaders(message, { baseUrl, token });
}

function startContentFilter(deps: FilterDeps, port: number): net.Server {

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.

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.

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.

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.

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.

Comment on lines +11 to +30
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_tls_auth_only=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_sender_restrictions=reject_sender_login_mismatch
-o { smtpd_relay_restrictions = permit_sasl_authenticated, reject }
-o { smtpd_recipient_restrictions = check_policy_service inet:127.0.0.1:<%= helper.policyPort %>, permit_sasl_authenticated, reject }
-o content_filter=mailhelper:[127.0.0.1]:<%= helper.filterPort %>
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_sender_restrictions=reject_sender_login_mismatch
-o { smtpd_relay_restrictions = permit_sasl_authenticated, reject }
-o { smtpd_recipient_restrictions = check_policy_service inet:127.0.0.1:<%= helper.policyPort %>, permit_sasl_authenticated, reject }
-o content_filter=mailhelper:[127.0.0.1]:<%= helper.filterPort %>

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.

I've never seen this syntax, shouldn't this be in main.cf or similar?

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.

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.)

Comment thread agent/templates/mail/postfix-main.cf.ejs

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.

We have previously agreed no in-app rate limits. Rate limits may be applied in the proxy at a future date but should stay out-of-scope for this PR.

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.

Removed

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.

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?

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.

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.
Copilot AI balanced review requested due to automatic review settings October 12, 2026 21:54
order: [['createdAt', 'DESC']],
});
if (!key) throw new ApiError(404, 'not_found', 'No active DKIM key for this domain');
console.warn(`AUDIT: DKIM private key for ${d.name} exported by admin ${req.session.user}`);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Mail delivery currently has blocking database-chroot, TLS, claim-handling, lifecycle, and compatibility defects.

11 open findings
12 resolved since last review
Files not reviewed (1)
  • mail-helper/package-lock.json: Generated file

🧠 Review effort: Balanced

# TLS: per-domain certs via SNI (compiled with `postmap -F`), shared with
# nginx under /etc/ssl. Fallback chain covers domains without a cert yet.
tls_server_sni_maps = hash:/etc/postfix/sni_map
smtpd_tls_chain_files = <%= certs.fallbackKey %>, <%= certs.fallbackCert %>
# live from SQL so unknown recipients are rejected at RCPT (no backscatter).
# Dovecot's quota-status policy service additionally rejects over-quota.
virtual_mailbox_domains = texthash:/etc/postfix/vdomains
virtual_mailbox_maps = <%= db.prefix %>:/etc/postfix/sql/mailboxes.cf
Comment on lines +108 to +113
if (mailClaim.status === 'holder') {
if (isLocalhostRequest(req) || req.secure) {
config.mail = await buildMailSnapshot(dbConfig);
} else {
config.mailStatus = 'insecure_transport';
}
Comment thread agent/src/index.ts Outdated
for (const svc of enabledServices(cfg)) {
state.lastApply[svc.unit] = await applyService(svc, result.config, cfg);
}
await syncMailLifecycle(cfg, result.config);
# Managed by opensource-agent (mail service group) — do not edit.
Syslog yes
UMask 007
Mode s
Comment on lines +58 to +63
<%_ if (mail.relayhost.username) { _%>
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = texthash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
<%_ } _%>
<%_ } _%>
Comment on lines +42 to +43
`CREATE USER IF NOT EXISTS 'mail_dovecot'@'%' IDENTIFIED BY '${throwaway()}'`,
`CREATE USER IF NOT EXISTS 'mail_postfix'@'%' IDENTIFIED BY '${throwaway()}'`,
Comment on lines +68 to +75
const holder = await Agent.findOne({
where: { mailHostSince: { [Op.ne]: null } },
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!holder) {
await agent.update({ mailHostSince: new Date() }, { transaction });
return { status: 'holder' };
Comment thread mail-helper/package.json
"pg": "^8.16.3"
},
"engines": {
"node": ">=20.18.1"
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.
Copilot AI balanced review requested due to automatic review settings October 13, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Runtime mail delivery, DKIM chaining, DNS verification, and service lifecycle defects must be resolved before deployment.

15 open findings
Files not reviewed (1)
  • mail-helper/package-lock.json: Generated file
Previously missed (2)

In code that hasn't changed since last review

Medium severity Unsubscribe base URL lacks HTTPS and path validation

create-a-container/​routers/​api/​v1/​settings.js:131

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.

Medium severity 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.

🧠 Review effort: Balanced

Comment thread agent/src/apply.ts
Comment on lines +155 to +157
} catch (err) {
log.warn(`${svc.unit}: could not reconcile attributes on ${f.dest}: ${(err as Error).message}`);
}
Comment on lines +120 to +121
// Mail enablement (issue #67). Send gate = mailEnabled && mailDnsVerified;
// receive gate = mailEnabled && mailMxVerified. Re-verification is manual.
Comment thread mail-helper/src/index.js
} finally {
transactions.delete(ctx);
}
return Decision.accept();
Comment thread agent/src/index.ts
for (const svc of enabledServices(cfg)) {
state.lastApply[svc.unit] = await applyService(svc, result.config, cfg);
}
await syncMailLifecycle(cfg, result.config, state);
Comment thread agent/src/apply.ts Outdated
}

/** Render a template from the shared templates dir (used by mail.ts too). */
export { renderTemplate };

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.

You can just export function ... the function right above this?

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.

f9b7766 renderTemplate is now declared export function directly; the separate export { renderTemplate } is gone.

Comment thread agent/src/apply.ts Outdated
Comment on lines +140 to +141
fs.writeFileSync(tmp, content, mode !== undefined ? { mode } : {});
if (owner) run(['chown', owner, tmp]);

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.

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.

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).

Comment thread agent/src/system.ts Outdated

// sbin dirs are not on the agent's PATH under systemd, but that's where
// postfix/dovecot/dnsmasq binaries live.
const EXTRA_BIN_DIRS = ['/usr/sbin', '/usr/bin', '/sbin', '/bin', '/usr/local/sbin', '/usr/local/bin'];

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.

This is pointless fluff. If the binary exists in one of these, but not in $PATH, the service is still gonna fail.

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.

EXTRA_BIN_DIRS is gone; findMissingBinaries only searches $PATH now.

Comment thread agent/src/mail.ts Outdated
Comment on lines +33 to +35
const VMAIL_DIR = '/var/vmail';
const GC_STATE_FILE = path.join(VMAIL_DIR, '.gc-state.json');
const GC_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;

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.

Good start, but I was actually referencing VMAIL_DIR, GC_STATE_FILE, and GC_RETENTION_MS

Comment thread agent/src/mail.ts Outdated
Comment on lines +51 to +58
function fileExists(file: string): boolean {
try {
fs.accessSync(file);
return true;
} catch {
return false;
}
}

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.

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.

the hand-rolled fileExists helper is deleted; call sites use fs.existsSync directly.

Comment thread agent/src/mail.ts Outdated
sh(['chown', 'opendkim:postfix', OPENDKIM_SOCKET_DIR]);
sh(['chmod', '2750', OPENDKIM_SOCKET_DIR]);
try {
sh(['usermod', '-a', '-G', 'opendkim', 'postfix']);

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.

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

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.

I've modified this in f9b7766 so that the opendkim process kicks off in the postfix group

Comment thread agent/templates/mail/opendkim.conf.ejs Outdated
# Unix socket inside the Postfix chroot (no inet port); postfix is a member
# of the opendkim group to connect.
Socket <%= opendkimSocket %>
UserID opendkim

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.

Per http://www.opendkim.org/opendkim.conf.5.html change this to opendkim:postfix so you don't have to add postfix to the opendkim group.

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.

done in f9b7766

Comment thread agent/templates/mail/postfix-master.cf.ejs
// oauth2-proxy can never block RFC 8058 POSTs from mail clients.
let unsubscribeHost = null;
try {
const baseUrl = await Setting.get('mail_unsubscribe_base_url');

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.

This should be derived from the manager URL rather than set seperately.

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.

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.

Comment thread agent/templates/nginx.conf.ejs Outdated
Comment on lines +239 to +251
<%_ if (servesUnsubscribe && authRequired && authServer) { _%>
<%_ /* RFC 8058 one-click unsubscribe: POSTs come from recipients' mail
clients with no session, so this location must stay outside
auth_request. The endpoint authenticates via its encrypted token. */ -%>
location ^~ /u/ {
proxy_pass <%= service.backendProtocol %>://<%= service.container.ipv4Address %>:<%= service.internalPort %>;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
<%_ } _%>

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.

The manager should be serving this and since the manager is never behind the oauth2-proxy this is a redundant check and template section.

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.

Removed so the /u/ location block, the nginx.unsubscribeHost snapshot field, and the agent-side type are all gone. The manager serves /u/ itself.

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.
Copilot AI balanced review requested due to automatic review settings October 26, 2026 20:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical security and mail configuration issues, alongside service lifecycle and retention defects, block approval.

23 open issues

And 3 more that still need to be addressed.

3 previously missed issues

In code that hasn't changed since last review

Medium severity 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.

Medium severity HTML-escapes Postfix database passwords

agent/​templates/​mail/​postfix-sql-mailboxes.cf.ejs:6

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.

Medium severity HTML-escapes Postfix database passwords

agent/​templates/​mail/​postfix-sql-senders.cf.ejs:5

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.

1 file not reviewed
  • mail-helper/package-lock.json: Generated file

Review effort: Balanced

Comment on lines +32 to +36
<%= db.dovecotDriver %> <%= db.dovecotHost %> {
<%_ if (db.dovecotParams.dbname) { _%>
dbname = <%= db.dovecotParams.dbname %>
<%_ } _%>
user = <%= db.dovecotUser %>
smtpd_recipient_restrictions = reject_unauth_destination, check_policy_service unix:private/quota-status

# Sender ownership: submission enforces sender == SASL login (live from SQL).
smtpd_sender_login_maps = <%= db.prefix %>:/etc/postfix/sql/senders.cf
Comment on lines +85 to +89
mailDnsVerified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'SPF/DKIM/DMARC check passed. mailEnabled && mailDnsVerified => domain can send'
Comment thread create-a-container/utils/agent-config.js Outdated
Type=simple
User=opensource-mail
# postfix group so the chrooted smtpd can connect to the milter socket.
Group=postfix
Comment thread mail-helper/src/db.js
Comment on lines +29 to +32
...(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,
Comment thread mail-helper/src/filter.js Outdated
Comment thread mail-helper/src/index.js
Comment on lines +75 to +79
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);
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 20:56
Co-authored-by: cmyers-mieweb <216552724+cmyers-mieweb@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved security and mail-delivery defects—including DKIM authorization, credential transport, host fencing, and service/database failures—block approval.

26 open issues

And 6 more that still need to be addressed.

5 previously missed issues

In code that hasn't changed since last review

Medium severity 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.

Medium severity 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.

Medium severity Preserve Postfix SQL map passwords without HTML escaping

agent/​templates/​mail/​postfix-sql-mailboxes.cf.ejs:6

[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.

Medium severity Emit sender map passwords without HTML escaping

agent/​templates/​mail/​postfix-sql-senders.cf.ejs:5

[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.

Medium severity 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.

2 resolved since last review
1 file not reviewed
  • mail-helper/package-lock.json: Generated file

Review effort: Balanced

Comment thread agent/src/index.ts
Comment on lines +84 to +85
} catch (err) {
log.error(`mail: unit lifecycle sync failed: ${(err as Error).message}`);
@@ -0,0 +1,3 @@
<%_ mail.dkim.forEach((k) => { _%>
*@<%= k.domain %> <%= k.selector %>._domainkey.<%= k.domain %>
Comment on lines +50 to +51
submission_recipient_restrictions = permit_sasl_authenticated, reject
submission_milters = <%= helperMilter %>, <%= opendkimMilter %>
/** Admin release — the next qualifying check-in takes the claim. */
async function releaseMailHost() {
const holder = await getMailHostAgent();
if (holder) await holder.update({ mailHostSince: null });
Comment thread mail-helper/src/index.js
Comment on lines +64 to +65
ctx.replyCode('550', decision.message);
return Decision.reject();
Copilot AI balanced review requested due to automatic review settings October 26, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

A critical Postfix database-access failure and unresolved authentication, TLS, DKIM, unsubscribe, DNS-validation, and lifecycle issues block approval.

27 open issues

And 7 more that still need to be addressed.

4 previously missed issues

In code that hasn't changed since last review

Medium severity Avoid HTML-escaping Dovecot database passwords

agent/​templates/​mail/​dovecot.conf.ejs:38

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.

Medium severity Avoid HTML-escaping Postfix database passwords

agent/​templates/​mail/​postfix-sql-mailboxes.cf.ejs:6

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.

Medium severity Emit literal Postfix passwords with config-safe escaping

agent/​templates/​mail/​postfix-sql-senders.cf.ejs:5

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.

Medium severity 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.

1 file not reviewed
  • mail-helper/package-lock.json: Generated file

Review effort: Balanced

# parameters and referenced here as $name, keeping every -o single-valued.
#
# service type private unpriv chroot wakeup maxproc command + args
smtp inet n - y - - smtpd
Comment on lines 34 to 35
smtpUrl: z.string(),
smtpNoreplyAddress: z.string(),

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

new service: email (SMTP submission + IMAP) hosted on the manager

5 participants