From 13f34f865bef4a941e7e1d87478ddfcda9f744e7 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 28 Oct 2026 15:51:41 -0700 Subject: [PATCH 01/10] mail: manager backend - schema, mail accounts API, DKIM/DNS, unsubscribe (#67) Split 1/4 of #495. Adds the manager-side mail foundation: - DB migrations, models, SQL views, and mail DB roles - /api/v1/mail-accounts resource with suppressions - Per-domain mail enablement, DKIM keys, and Check DNS - Mail-host claim and agent snapshot mail section - One-click unsubscribe (keys, tokens, public /u route) - OpenAPI spec for mail endpoints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- create-a-container/app.js | 7 + ...000000-add-external-domain-mail-columns.js | 30 ++ .../20261008000001-create-dkim-keys.js | 30 ++ .../20261008000002-create-mail-accounts.js | 38 ++ .../20261008000003-add-agent-mail-fields.js | 56 +++ ...20261008000004-create-mail-suppressions.js | 28 ++ ...1008000005-create-mail-unsubscribe-keys.js | 21 ++ .../20261008000006-create-mail-views.js | 49 +++ .../20261008000007-backfill-dkim-keys.js | 36 ++ .../20261012000000-create-mail-db-roles.js | 79 +++++ create-a-container/models/agent.js | 29 ++ create-a-container/models/dkim-key.js | 57 +++ create-a-container/models/external-domain.js | 35 ++ create-a-container/models/mail-account.js | 95 +++++ create-a-container/models/mail-suppression.js | 50 +++ .../models/mail-unsubscribe-key.js | 61 ++++ create-a-container/openapi.v1.yaml | 327 +++++++++++++++++- .../mailaccounts/__tests__/service.test.js | 153 ++++++++ .../resources/mailaccounts/controller.js | 80 +++++ .../resources/mailaccounts/repository.js | 91 +++++ .../resources/mailaccounts/router.js | 33 ++ .../resources/mailaccounts/serializer.js | 38 ++ .../resources/mailaccounts/service.js | 169 +++++++++ .../resources/mailaccounts/validator.js | 45 +++ create-a-container/routers/api/v1/agents.js | 56 ++- .../routers/api/v1/external-domains.js | 164 ++++++++- create-a-container/routers/api/v1/index.js | 2 + create-a-container/routers/api/v1/mail.js | 70 ++++ create-a-container/routers/api/v1/settings.js | 53 ++- create-a-container/routers/unsubscribe.js | 95 +++++ .../utils/__tests__/mail-dns.test.js | 140 ++++++++ .../utils/__tests__/unsubscribe-token.test.js | 50 +++ create-a-container/utils/agent-config.js | 84 ++++- create-a-container/utils/dkim.js | 36 ++ create-a-container/utils/mail-account.js | 41 +++ create-a-container/utils/mail-dns.js | 249 +++++++++++++ create-a-container/utils/mail-host.js | 95 +++++ create-a-container/utils/mail-views.js | 80 +++++ create-a-container/utils/unsubscribe-token.js | 78 +++++ .../docs/developers/database-schema.md | 77 ++++- 40 files changed, 2989 insertions(+), 18 deletions(-) create mode 100644 create-a-container/migrations/20261008000000-add-external-domain-mail-columns.js create mode 100644 create-a-container/migrations/20261008000001-create-dkim-keys.js create mode 100644 create-a-container/migrations/20261008000002-create-mail-accounts.js create mode 100644 create-a-container/migrations/20261008000003-add-agent-mail-fields.js create mode 100644 create-a-container/migrations/20261008000004-create-mail-suppressions.js create mode 100644 create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js create mode 100644 create-a-container/migrations/20261008000006-create-mail-views.js create mode 100644 create-a-container/migrations/20261008000007-backfill-dkim-keys.js create mode 100644 create-a-container/migrations/20261012000000-create-mail-db-roles.js create mode 100644 create-a-container/models/dkim-key.js create mode 100644 create-a-container/models/mail-account.js create mode 100644 create-a-container/models/mail-suppression.js create mode 100644 create-a-container/models/mail-unsubscribe-key.js create mode 100644 create-a-container/resources/mailaccounts/__tests__/service.test.js create mode 100644 create-a-container/resources/mailaccounts/controller.js create mode 100644 create-a-container/resources/mailaccounts/repository.js create mode 100644 create-a-container/resources/mailaccounts/router.js create mode 100644 create-a-container/resources/mailaccounts/serializer.js create mode 100644 create-a-container/resources/mailaccounts/service.js create mode 100644 create-a-container/resources/mailaccounts/validator.js create mode 100644 create-a-container/routers/api/v1/mail.js create mode 100644 create-a-container/routers/unsubscribe.js create mode 100644 create-a-container/utils/__tests__/mail-dns.test.js create mode 100644 create-a-container/utils/__tests__/unsubscribe-token.test.js create mode 100644 create-a-container/utils/dkim.js create mode 100644 create-a-container/utils/mail-account.js create mode 100644 create-a-container/utils/mail-dns.js create mode 100644 create-a-container/utils/mail-host.js create mode 100644 create-a-container/utils/mail-views.js create mode 100644 create-a-container/utils/unsubscribe-token.js diff --git a/create-a-container/app.js b/create-a-container/app.js index a4e466d2..47ff3b5d 100644 --- a/create-a-container/app.js +++ b/create-a-container/app.js @@ -99,6 +99,13 @@ function buildApp({ app.use(express.static('public')); + // Public one-click unsubscribe (RFC 8058) — mounted before the CSRF guard + // because requests come from recipients' mail clients with no session or + // token (authentication is the encrypted unsubscribe token itself). Served + // without oauth2-proxy interference via the dedicated /u/ location in the + // agent's nginx template. + app.use('/u', require('./routers/unsubscribe')); + // CSRF guard for every handler that can see the session cookie (CodeQL // js/missing-token-validation). Behavior-preserving: csrfGuard skips // GET/HEAD/OPTIONS and Bearer-only requests, and every state-changing diff --git a/create-a-container/migrations/20261008000000-add-external-domain-mail-columns.js b/create-a-container/migrations/20261008000000-add-external-domain-mail-columns.js new file mode 100644 index 00000000..69bf349e --- /dev/null +++ b/create-a-container/migrations/20261008000000-add-external-domain-mail-columns.js @@ -0,0 +1,30 @@ +'use strict'; + +/** Mail enablement + DNS verification state on external domains (issue #67). */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('ExternalDomains', 'mailEnabled', { + type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, + }); + await queryInterface.addColumn('ExternalDomains', 'mailDnsVerified', { + type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, + }); + await queryInterface.addColumn('ExternalDomains', 'mailMxVerified', { + type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, + }); + await queryInterface.addColumn('ExternalDomains', 'mailDnsCheckedAt', { + type: Sequelize.DATE, allowNull: true, + }); + await queryInterface.addColumn('ExternalDomains', 'mailDnsCheckResult', { + type: Sequelize.JSON, allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('ExternalDomains', 'mailDnsCheckResult'); + await queryInterface.removeColumn('ExternalDomains', 'mailDnsCheckedAt'); + await queryInterface.removeColumn('ExternalDomains', 'mailMxVerified'); + await queryInterface.removeColumn('ExternalDomains', 'mailDnsVerified'); + await queryInterface.removeColumn('ExternalDomains', 'mailEnabled'); + }, +}; diff --git a/create-a-container/migrations/20261008000001-create-dkim-keys.js b/create-a-container/migrations/20261008000001-create-dkim-keys.js new file mode 100644 index 00000000..92ed3740 --- /dev/null +++ b/create-a-container/migrations/20261008000001-create-dkim-keys.js @@ -0,0 +1,30 @@ +'use strict'; + +/** DKIM key pairs per external domain (issue #67). */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('DkimKeys', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + externalDomainId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'ExternalDomains', key: 'id' }, + onDelete: 'CASCADE', + }, + selector: { type: Sequelize.STRING(63), allowNull: false }, + privateKey: { type: Sequelize.TEXT, allowNull: false }, + publicKey: { type: Sequelize.TEXT, allowNull: false }, + status: { type: Sequelize.ENUM('active', 'retired'), allowNull: false, defaultValue: 'active' }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex('DkimKeys', ['externalDomainId', 'selector'], { unique: true }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('DkimKeys'); + if (queryInterface.sequelize.getDialect() === 'postgres') { + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_DkimKeys_status";'); + } + }, +}; diff --git a/create-a-container/migrations/20261008000002-create-mail-accounts.js b/create-a-container/migrations/20261008000002-create-mail-accounts.js new file mode 100644 index 00000000..21e816ab --- /dev/null +++ b/create-a-container/migrations/20261008000002-create-mail-accounts.js @@ -0,0 +1,38 @@ +'use strict'; + +/** Per-user email service accounts (issue #67). */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('MailAccounts', { + id: { type: Sequelize.UUID, primaryKey: true, allowNull: false }, + uidNumber: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Users', key: 'uidNumber' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + externalDomainId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'ExternalDomains', key: 'id' }, + onDelete: 'CASCADE', + }, + localPart: { type: Sequelize.STRING(64), allowNull: false }, + description: { type: Sequelize.STRING(255), allowNull: true }, + passwordHash: { type: Sequelize.STRING(255), allowNull: false }, + enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + quotaBytes: { type: Sequelize.BIGINT, allowNull: false }, + unsubscribeHeaders: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + lastRotatedAt: { type: Sequelize.DATE, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex('MailAccounts', ['externalDomainId', 'localPart'], { unique: true }); + await queryInterface.addIndex('MailAccounts', ['uidNumber']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('MailAccounts'); + }, +}; diff --git a/create-a-container/migrations/20261008000003-add-agent-mail-fields.js b/create-a-container/migrations/20261008000003-add-agent-mail-fields.js new file mode 100644 index 00000000..737e52e0 --- /dev/null +++ b/create-a-container/migrations/20261008000003-add-agent-mail-fields.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Agent service reporting + the mail-host claim (issue #67). + * + * The partial unique index over the constant (1) allows at most ONE row in + * the whole table with a non-null mailHostSince — the single-mail-host + * invariant enforced at the DB level on Postgres and SQLite. MySQL/MariaDB + * have no partial indexes; there the claim is serialized in application code + * (utils/mail-host.js runs it inside a transaction). + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('Agents', 'enabledServices', { + type: Sequelize.JSON, allowNull: true, + }); + await queryInterface.addColumn('Agents', 'missingBinaries', { + type: Sequelize.JSON, allowNull: true, + }); + await queryInterface.addColumn('Agents', 'mailHostSince', { + type: Sequelize.DATE, allowNull: true, + }); + await queryInterface.addColumn('Agents', 'isLocal', { + type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, + }); + await queryInterface.addColumn('Agents', 'apiKeyId', { + type: Sequelize.UUID, + allowNull: true, + references: { model: 'ApiKeys', key: 'id' }, + onDelete: 'SET NULL', + }); + + const dialect = queryInterface.sequelize.getDialect(); + if (dialect === 'postgres') { + await queryInterface.sequelize.query( + 'CREATE UNIQUE INDEX "agents_single_mail_host" ON "Agents" ((1)) WHERE "mailHostSince" IS NOT NULL;', + ); + } else if (dialect === 'sqlite') { + await queryInterface.sequelize.query( + 'CREATE UNIQUE INDEX `agents_single_mail_host` ON `Agents` (1) WHERE `mailHostSince` IS NOT NULL;', + ); + } + }, + + async down(queryInterface) { + const dialect = queryInterface.sequelize.getDialect(); + if (dialect === 'postgres' || dialect === 'sqlite') { + await queryInterface.sequelize.query('DROP INDEX IF EXISTS "agents_single_mail_host";'); + } + await queryInterface.removeColumn('Agents', 'apiKeyId'); + await queryInterface.removeColumn('Agents', 'isLocal'); + await queryInterface.removeColumn('Agents', 'mailHostSince'); + await queryInterface.removeColumn('Agents', 'missingBinaries'); + await queryInterface.removeColumn('Agents', 'enabledServices'); + }, +}; diff --git a/create-a-container/migrations/20261008000004-create-mail-suppressions.js b/create-a-container/migrations/20261008000004-create-mail-suppressions.js new file mode 100644 index 00000000..f70ed6ae --- /dev/null +++ b/create-a-container/migrations/20261008000004-create-mail-suppressions.js @@ -0,0 +1,28 @@ +'use strict'; + +/** One-click unsubscribe suppressions per mail account (issue #67). */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('MailSuppressions', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false }, + mailAccountId: { + type: Sequelize.UUID, + allowNull: false, + references: { model: 'MailAccounts', key: 'id' }, + onDelete: 'CASCADE', + }, + recipient: { type: Sequelize.STRING(320), allowNull: false }, + source: { type: Sequelize.ENUM('one-click', 'admin'), allowNull: false, defaultValue: 'one-click' }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex('MailSuppressions', ['mailAccountId', 'recipient'], { unique: true }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('MailSuppressions'); + if (queryInterface.sequelize.getDialect() === 'postgres') { + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_MailSuppressions_source";'); + } + }, +}; diff --git a/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js b/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js new file mode 100644 index 00000000..a4d14c86 --- /dev/null +++ b/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js @@ -0,0 +1,21 @@ +'use strict'; + +/** AES-256-GCM keys for unsubscribe tokens (issue #67). */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('MailUnsubscribeKeys', { + kid: { type: Sequelize.STRING(16), primaryKey: true, allowNull: false }, + secret: { type: Sequelize.STRING(64), allowNull: false }, + status: { type: Sequelize.ENUM('active', 'retired'), allowNull: false, defaultValue: 'active' }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('MailUnsubscribeKeys'); + if (queryInterface.sequelize.getDialect() === 'postgres') { + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_MailUnsubscribeKeys_status";'); + } + }, +}; diff --git a/create-a-container/migrations/20261008000006-create-mail-views.js b/create-a-container/migrations/20261008000006-create-mail-views.js new file mode 100644 index 00000000..a583ecdb --- /dev/null +++ b/create-a-container/migrations/20261008000006-create-mail-views.js @@ -0,0 +1,49 @@ +'use strict'; + +const { createViewsSql, dropViewsSql } = require('../utils/mail-views'); + +/** + * The mail SQL views — the stable contract Dovecot/Postfix (or a + * self-managed MTA) read live. See utils/mail-views.js for the shape. + * Grants are NOT done here: bin/setup-mail-db-roles.sh creates the + * mail_dovecot / mail_postfix users and grants SELECT on the views. + */ +module.exports = { + async up(queryInterface) { + const dialect = queryInterface.sequelize.getDialect(); + for (const sql of createViewsSql(dialect)) { + await queryInterface.sequelize.query(sql); + } + // Grant SELECT when the roles already exist (they are created by + // migration 20261012000000; each side grants best-effort so either + // ordering converges). + const grants = []; + if (dialect === 'postgres') { + grants.push( + 'GRANT SELECT ON mail_accounts_v TO mail_dovecot', + 'GRANT SELECT ON mail_senders_v, mail_recipients_v, mail_suppressions_v TO mail_postfix', + ); + } else if (dialect === 'mysql' || dialect === 'mariadb') { + const db = queryInterface.sequelize.config.database; + grants.push( + `GRANT SELECT ON \`${db}\`.mail_accounts_v TO 'mail_dovecot'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_senders_v TO 'mail_postfix'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_recipients_v TO 'mail_postfix'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_suppressions_v TO 'mail_postfix'@'%'`, + ); + } + for (const sql of grants) { + try { + await queryInterface.sequelize.query(sql); + } catch { + /* roles not created yet — migration 20261012000000 grants then */ + } + } + }, + + async down(queryInterface) { + for (const sql of dropViewsSql()) { + await queryInterface.sequelize.query(sql); + } + }, +}; diff --git a/create-a-container/migrations/20261008000007-backfill-dkim-keys.js b/create-a-container/migrations/20261008000007-backfill-dkim-keys.js new file mode 100644 index 00000000..065b525e --- /dev/null +++ b/create-a-container/migrations/20261008000007-backfill-dkim-keys.js @@ -0,0 +1,36 @@ +'use strict'; + +const { generateDkimKeyPair } = require('../utils/dkim'); + +/** + * Backfill DKIM keys for domains created before issue #67. New domains get + * a key at create time (routers/api/v1/external-domains.js). + */ +module.exports = { + async up(queryInterface) { + const mysql = ['mysql', 'mariadb'].includes(queryInterface.sequelize.getDialect()); + const q = mysql ? (s) => `\`${s}\`` : (s) => `"${s}"`; + const [domains] = await queryInterface.sequelize.query( + `SELECT id FROM ${q('ExternalDomains')} WHERE id NOT IN (SELECT ${q('externalDomainId')} FROM ${q('DkimKeys')})`, + ); + const now = new Date(); + for (const { id } of domains) { + const { selector, privateKey, publicKey } = generateDkimKeyPair(); + await queryInterface.bulkInsert('DkimKeys', [{ + externalDomainId: id, + selector, + privateKey, + publicKey, + status: 'active', + createdAt: now, + updatedAt: now, + }]); + } + }, + + async down(queryInterface) { + // Backfill only — keys for still-existing domains are kept on rollback + // of this migration; dropping the table removes them anyway. + void queryInterface; + }, +}; diff --git a/create-a-container/migrations/20261012000000-create-mail-db-roles.js b/create-a-container/migrations/20261012000000-create-mail-db-roles.js new file mode 100644 index 00000000..2b2dec8b --- /dev/null +++ b/create-a-container/migrations/20261012000000-create-mail-db-roles.js @@ -0,0 +1,79 @@ +'use strict'; + +const crypto = require('crypto'); + +/** + * Read-only DB roles for the mail stack (issue #67, replaces + * bin/setup-mail-db-roles.sh per PR #495 review): mail_dovecot (SELECT on + * mail_accounts_v) and mail_postfix (SELECT on mail_senders_v + + * mail_suppressions_v). + * + * Local mail hosts use socket auth (Postgres peer via the manager image's + * pg_ident map), so the roles are created without a usable password — on + * MySQL a random throwaway password blocks TCP logins. Remote mail hosts + * need the operator to set one (ALTER ROLE ... PASSWORD / ALTER USER). + * + * Role creation needs CREATEROLE (granted to the app user by + * container-creator-init.service). Where the app user lacks the privilege, + * the statements are logged for a DBA and the migration continues — mail + * simply stays unavailable until the roles exist. + */ +module.exports = { + async up(queryInterface) { + const dialect = queryInterface.sequelize.getDialect(); + let statements = []; + if (dialect === 'postgres') { + statements = [ + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'mail_dovecot') THEN + CREATE ROLE mail_dovecot LOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'mail_postfix') THEN + CREATE ROLE mail_postfix LOGIN; + END IF; + END $$;`, + 'GRANT SELECT ON mail_accounts_v TO mail_dovecot', + 'GRANT SELECT ON mail_senders_v, mail_recipients_v, mail_suppressions_v TO mail_postfix', + ]; + } else if (dialect === 'mysql' || dialect === 'mariadb') { + const db = queryInterface.sequelize.config.database; + const throwaway = () => crypto.randomBytes(24).toString('base64url'); + statements = [ + `CREATE USER IF NOT EXISTS 'mail_dovecot'@'%' IDENTIFIED BY '${throwaway()}'`, + `CREATE USER IF NOT EXISTS 'mail_postfix'@'%' IDENTIFIED BY '${throwaway()}'`, + `GRANT SELECT ON \`${db}\`.mail_accounts_v TO 'mail_dovecot'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_senders_v TO 'mail_postfix'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_recipients_v TO 'mail_postfix'@'%'`, + `GRANT SELECT ON \`${db}\`.mail_suppressions_v TO 'mail_postfix'@'%'`, + ]; + } else { + return; // SQLite cannot host mail; the views exist for tests only. + } + try { + for (const sql of statements) { + await queryInterface.sequelize.query(sql); + } + } catch (err) { + console.warn( + 'create-mail-db-roles: could not create the mail DB roles ' + + `(${err.message}). Run the following as a privileged DB user:\n` + + statements.join(';\n'), + ); + } + }, + + async down(queryInterface) { + const dialect = queryInterface.sequelize.getDialect(); + try { + if (dialect === 'postgres') { + await queryInterface.sequelize.query('DROP ROLE IF EXISTS mail_dovecot'); + await queryInterface.sequelize.query('DROP ROLE IF EXISTS mail_postfix'); + } else if (dialect === 'mysql' || dialect === 'mariadb') { + await queryInterface.sequelize.query("DROP USER IF EXISTS 'mail_dovecot'@'%'"); + await queryInterface.sequelize.query("DROP USER IF EXISTS 'mail_postfix'@'%'"); + } + } catch { + /* lacking privilege — leave the roles for a DBA */ + } + }, +}; diff --git a/create-a-container/models/agent.js b/create-a-container/models/agent.js index 16d36740..297402bd 100644 --- a/create-a-container/models/agent.js +++ b/create-a-container/models/agent.js @@ -28,6 +28,35 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.JSON, allowNull: true }, + enabledServices: { + // Service groups the agent is configured to run (AGENT_SERVICES), + // e.g. ['nginx', 'dnsmasq', 'mail']. + type: DataTypes.JSON, + allowNull: true + }, + missingBinaries: { + // Binaries/SQL drivers the agent reports missing for its enabled + // services, e.g. ['postfix', 'dovecot']. Blocks the mail-host claim. + type: DataTypes.JSON, + allowNull: true + }, + mailHostSince: { + // The mail-host claim: at most one agent holds it (partial unique + // index on (1) WHERE mailHostSince IS NOT NULL — Postgres/SQLite). + type: DataTypes.DATE, + allowNull: true + }, + isLocal: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + apiKeyId: { + // Pinned on the first remote check-in; later remote check-ins must + // present the same key. Cleared via DELETE /agents/:id/api-key-pin. + type: DataTypes.UUID, + allowNull: true + }, lastCheckinAt: { type: DataTypes.DATE, allowNull: true diff --git a/create-a-container/models/dkim-key.js b/create-a-container/models/dkim-key.js new file mode 100644 index 00000000..5bf4c6ab --- /dev/null +++ b/create-a-container/models/dkim-key.js @@ -0,0 +1,57 @@ +'use strict'; +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class DkimKey extends Model { + static associate(models) { + DkimKey.belongsTo(models.ExternalDomain, { + foreignKey: 'externalDomainId', + as: 'domain', + }); + } + } + + DkimKey.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + externalDomainId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'ExternalDomains', key: 'id' }, + onDelete: 'CASCADE', + }, + selector: { + type: DataTypes.STRING(63), + allowNull: false, + comment: 'DKIM selector — the DNS record lives at ._domainkey.', + }, + privateKey: { + type: DataTypes.TEXT, + allowNull: false, + comment: 'PEM RSA private key. Never serialized in API responses; sent only in the mail-host snapshot and the admin DKIM export.', + }, + publicKey: { + type: DataTypes.TEXT, + allowNull: false, + comment: 'Base64 DER (SPKI) public key — the p= value of the DNS TXT record', + }, + status: { + type: DataTypes.ENUM('active', 'retired'), + allowNull: false, + defaultValue: 'active', + }, + }, { + sequelize, + modelName: 'DkimKey', + tableName: 'DkimKeys', + timestamps: true, + indexes: [ + { unique: true, fields: ['externalDomainId', 'selector'] }, + ], + }); + + return DkimKey; +}; diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 62d0ff8b..2127c4f7 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -75,6 +75,33 @@ module.exports = (sequelize) => { } }, comment: "Address of the oauth2-proxy process for nginx auth_request, e.g. http://127.0.0.1:4180. nginx proxies /oauth2/* straight to it in a single hop; do not point this at a path-prefixed URL." + }, + mailEnabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Admin switch — mail for this domain is off until enabled AND DNS-verified' + }, + mailDnsVerified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'SPF/DKIM/DMARC check passed. mailEnabled && mailDnsVerified => domain can send' + }, + mailMxVerified: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'MX resolves to the mail IP. mailEnabled && mailMxVerified => domain can receive' + }, + mailDnsCheckedAt: { + type: DataTypes.DATE, + allowNull: true + }, + mailDnsCheckResult: { + type: DataTypes.JSON, + allowNull: true, + comment: 'Per-record outcome of the last Check DNS run (see utils/mail-dns.js)' } }, { tableName: 'ExternalDomains', @@ -86,6 +113,14 @@ module.exports = (sequelize) => { foreignKey: 'siteId', as: 'site' }); + ExternalDomain.hasMany(models.DkimKey, { + foreignKey: 'externalDomainId', + as: 'dkimKeys' + }); + ExternalDomain.hasMany(models.MailAccount, { + foreignKey: 'externalDomainId', + as: 'mailAccounts' + }); }; return ExternalDomain; diff --git a/create-a-container/models/mail-account.js b/create-a-container/models/mail-account.js new file mode 100644 index 00000000..882746ab --- /dev/null +++ b/create-a-container/models/mail-account.js @@ -0,0 +1,95 @@ +'use strict'; +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class MailAccount extends Model { + static associate(models) { + MailAccount.belongsTo(models.User, { + foreignKey: 'uidNumber', + as: 'owner', + }); + MailAccount.belongsTo(models.ExternalDomain, { + foreignKey: 'externalDomainId', + as: 'domain', + }); + MailAccount.hasMany(models.MailSuppression, { + foreignKey: 'mailAccountId', + as: 'suppressions', + }); + } + } + + MailAccount.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false, + }, + uidNumber: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'Users', key: 'uidNumber' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + comment: 'Owning user — admin-transferable, never shared', + }, + externalDomainId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'ExternalDomains', key: 'id' }, + onDelete: 'CASCADE', + }, + localPart: { + type: DataTypes.STRING(64), + allowNull: false, + validate: { + // Conservative RFC 5321 dot-atom subset, stored lowercase — single + // separators only (no consecutive dots), matching the API validator. + is: /^[a-z0-9]+(?:[._+-][a-z0-9]+)*$/, + }, + comment: 'Lowercase local part — address is @', + }, + description: { + type: DataTypes.STRING(255), + allowNull: true, + }, + passwordHash: { + type: DataTypes.STRING(255), + allowNull: false, + comment: 'Argon2id PHC string (parallelism 1 for Dovecot compatibility). Never serialized.', + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: 'Disabled accounts disappear from the SQL views — instant lockout', + }, + quotaBytes: { + type: DataTypes.BIGINT, + allowNull: false, + comment: 'Mailbox quota enforced by Dovecot (quota-status also rejects at RCPT when over)', + }, + unsubscribeHeaders: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: 'Add RFC 8058 List-Unsubscribe headers to submitted mail', + }, + lastRotatedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, { + sequelize, + modelName: 'MailAccount', + tableName: 'MailAccounts', + timestamps: true, + indexes: [ + { unique: true, fields: ['externalDomainId', 'localPart'] }, + { fields: ['uidNumber'] }, + ], + }); + + return MailAccount; +}; diff --git a/create-a-container/models/mail-suppression.js b/create-a-container/models/mail-suppression.js new file mode 100644 index 00000000..5ae437fb --- /dev/null +++ b/create-a-container/models/mail-suppression.js @@ -0,0 +1,50 @@ +'use strict'; +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + class MailSuppression extends Model { + static associate(models) { + MailSuppression.belongsTo(models.MailAccount, { + foreignKey: 'mailAccountId', + as: 'account', + }); + } + } + + MailSuppression.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + mailAccountId: { + type: DataTypes.UUID, + allowNull: false, + references: { model: 'MailAccounts', key: 'id' }, + onDelete: 'CASCADE', + }, + recipient: { + type: DataTypes.STRING(320), + allowNull: false, + set(value) { + this.setDataValue('recipient', String(value || '').trim().toLowerCase()); + }, + comment: 'Lowercased recipient address that unsubscribed from this account', + }, + source: { + type: DataTypes.ENUM('one-click', 'admin'), + allowNull: false, + defaultValue: 'one-click', + }, + }, { + sequelize, + modelName: 'MailSuppression', + tableName: 'MailSuppressions', + timestamps: true, + indexes: [ + { unique: true, fields: ['mailAccountId', 'recipient'] }, + ], + }); + + return MailSuppression; +}; diff --git a/create-a-container/models/mail-unsubscribe-key.js b/create-a-container/models/mail-unsubscribe-key.js new file mode 100644 index 00000000..3049f30b --- /dev/null +++ b/create-a-container/models/mail-unsubscribe-key.js @@ -0,0 +1,61 @@ +'use strict'; +const crypto = require('crypto'); +const { Model } = require('sequelize'); + +// Retired keys keep verifying previously issued unsubscribe links for this long. +const RETIRED_KEY_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; + +module.exports = (sequelize, DataTypes) => { + class MailUnsubscribeKey extends Model { + /** True when this key may still verify tokens (active, or retired within retention). */ + canVerify(now = Date.now()) { + if (this.status === 'active') return true; + return now - new Date(this.updatedAt).getTime() < RETIRED_KEY_RETENTION_MS; + } + + /** The single active key, created on first use. */ + static async ensureActive() { + const existing = await MailUnsubscribeKey.findOne({ + where: { status: 'active' }, + order: [['createdAt', 'DESC']], + }); + if (existing) return existing; + return MailUnsubscribeKey.create({ + kid: crypto.randomBytes(4).toString('hex'), + secret: crypto.randomBytes(32).toString('base64'), + }); + } + + /** Active + recently retired keys, newest first — the set that may verify tokens. */ + static async verifiable() { + const keys = await MailUnsubscribeKey.findAll({ order: [['createdAt', 'DESC']] }); + return keys.filter((k) => k.canVerify()); + } + } + + MailUnsubscribeKey.init({ + kid: { + type: DataTypes.STRING(16), + primaryKey: true, + allowNull: false, + comment: 'Short key id embedded (unencrypted) in unsubscribe tokens', + }, + secret: { + type: DataTypes.STRING(64), + allowNull: false, + comment: 'Base64 of 32 random bytes (AES-256-GCM key). Never serialized; sent only in the mail-host snapshot.', + }, + status: { + type: DataTypes.ENUM('active', 'retired'), + allowNull: false, + defaultValue: 'active', + }, + }, { + sequelize, + modelName: 'MailUnsubscribeKey', + tableName: 'MailUnsubscribeKeys', + timestamps: true, + }); + + return MailUnsubscribeKey; +}; diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 5c088ef9..037c44d7 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -49,6 +49,7 @@ tags: - name: Notifications - name: Jobs - name: External Domains + - name: Mail - name: Users - name: Groups - name: API Keys @@ -463,6 +464,10 @@ components: name: { type: string } authServer: { type: string, nullable: true } hasCloudflareApiKey: { type: boolean } + mailEnabled: { type: boolean, description: 'Read-only here — toggled via the …/mail/enable|disable endpoints' } + mailDnsVerified: { type: boolean, description: 'SPF+DKIM+DMARC verified. With mailEnabled => domain can send' } + mailMxVerified: { type: boolean, description: 'MX resolves to the mail IP. With mailEnabled => domain can receive' } + mailDnsCheckedAt: { type: string, format: date-time, nullable: true } ExternalDomainInput: type: object properties: @@ -493,6 +498,65 @@ components: netboxToken: { type: string } bannerMessage: { type: string, description: 'Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`.' } usagePsiProbeLimit: { type: string, description: 'Max PSI (pressure stall) probes per usage report, spent on the highest-utilization running containers. Non-negative integer as string; "0" disables PSI, blank uses the default (16).' } + mailHostname: { type: string, description: 'HELO / MX hostname — the forward-confirmed PTR name of the mail IP (see GET /mail/ptr)' } + mailRelayhost: { type: string, description: 'Optional smarthost; requires mailSpfInclude' } + mailRelayhostUsername: { type: string } + mailRelayhostPassword: { type: string } + mailSpfInclude: { type: string, description: 'include: domain added to suggested SPF; required with a relayhost' } + mailDnsCheckResolvers: { type: string, description: 'Comma-separated resolver IPs for Check DNS (default 1.1.1.1, 8.8.8.8)' } + mailDbHost: { type: string, description: 'DB host the mail stack connects to when the DB is not local to the mail host' } + mailSelfManagedSiteId: { type: string, description: 'Site whose externalIp is the mail IP when no agent runs mail (self-managed MTA)' } + mailDefaultQuotaMb: { type: string, description: 'Mailbox quota for new accounts (default 1024)' } + mailMessageSizeLimitMb: { type: string, description: 'Postfix message size limit (default 25)' } + MailAccount: + type: object + properties: + id: { type: string, format: uuid } + address: { type: string, description: '@' } + localPart: { type: string } + domain: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + canSend: { type: boolean } + canReceive: { type: boolean } + owner: { type: string, nullable: true, description: Username } + description: { type: string, nullable: true } + enabled: { type: boolean } + quotaMb: { type: integer } + unsubscribeHeaders: { type: boolean, description: 'Add RFC 8058 List-Unsubscribe headers to submitted mail' } + lastRotatedAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + MailAccountSecret: + allOf: + - $ref: '#/components/schemas/MailAccount' + - type: object + properties: + password: { type: string, description: 'Generated password — this is the only time it is returned' } + connection: + type: object + properties: + host: { type: string } + username: { type: string } + smtp: { type: object, properties: { ports: { type: array, items: { type: integer } } } } + imap: { type: object, properties: { ports: { type: array, items: { type: integer } } } } + warning: { type: string } + MailDnsRecord: + type: object + properties: + type: { type: string, enum: [TXT, MX] } + name: { type: string } + value: { type: string } + MailSuppression: + type: object + properties: + id: { type: integer } + recipient: { type: string } + source: { type: string, enum: [one-click, admin] } + createdAt: { type: string, format: date-time } ResourceRequest: type: object properties: @@ -1393,10 +1457,26 @@ paths: properties: applied: { type: boolean } message: { type: string, description: 'Failure detail (mkdir/chown error)' } + enabledServices: + type: array + items: { type: string } + description: 'Service groups the agent runs (AGENT_SERVICES), e.g. [nginx, dnsmasq, mail]' + missingBinaries: + type: array + items: { type: string } + description: Binaries/SQL drivers missing for the enabled services; blocks the mail-host claim responses: '200': - description: 'Config snapshot (`{ data: { site, nginx } }`) with `ETag` header' + description: >- + Config snapshot (`{ data: { site, nginx } }`) with `ETag` header. + When the agent reports `mail`, `data.mailStatus` is one of + holder|conflict|unsupported|missing_packages|insecure_transport + (remote holders only receive the secret-bearing `data.mail` + section over TLS), and only the claim holder additionally + receives `data.mail` (DKIM keys, receiving domains, relayhost, + limits, DB connection, unsubscribe keys). '304': { description: Config unchanged since If-None-Match } + '403': { description: 'Agent pinned to a different API key (code: agent_key_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '422': { description: 'Missing siteId/hostname (code: validation_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } get: operationId: list_agents @@ -1421,6 +1501,11 @@ paths: hostname: { type: string } ipv4Address: { type: string, nullable: true } services: { type: object, nullable: true } + enabledServices: { type: array, items: { type: string }, nullable: true } + missingBinaries: { type: array, items: { type: string }, nullable: true } + mailHostSince: { type: string, format: date-time, nullable: true } + isLocal: { type: boolean } + hasApiKeyPin: { type: boolean } lastCheckinAt: { type: string, format: date-time, nullable: true } secondsSinceCheckin: { type: integer, nullable: true, description: Computed server-side at response time } '403': { $ref: '#/components/responses/Forbidden' } @@ -1623,6 +1708,246 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } + /external-domains/{id}/mail/dns: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + get: + operationId: get_domain_mail_dns + tags: [Mail] + summary: DNS records to publish for mail + last check result (admin) + responses: + '200': + description: Records and verification state + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/MailDnsRecord' } } + mailEnabled: { type: boolean } + mailDnsVerified: { type: boolean } + mailMxVerified: { type: boolean } + lastCheck: { type: object, nullable: true } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + /external-domains/{id}/mail/check-dns: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + post: + operationId: check_domain_mail_dns + tags: [Mail] + summary: Re-verify SPF/DKIM/DMARC/MX against public resolvers (admin) + responses: + '200': + description: Domain with updated gates plus the full check result under `check` + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + /external-domains/{id}/mail/enable: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + post: + operationId: enable_domain_mail + tags: [Mail] + summary: Enable mail for a domain (admin) — runs Check DNS first + responses: + '200': { description: Enabled } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { description: 'SPF/DKIM/DMARC not verified (code: dns_not_configured); MX failure only warns', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /external-domains/{id}/mail/disable: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + post: + operationId: disable_domain_mail + tags: [Mail] + summary: Disable mail for a domain (admin) — DKIM keys and config are kept + responses: + '200': { description: Disabled } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + /external-domains/{id}/mail/dkim: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + get: + operationId: export_domain_dkim_key + tags: [Mail] + summary: Export the active DKIM key pair for a self-managed MTA (admin; audited in the logs) + responses: + '200': + description: '`{ domain, selector, privateKey, publicKey }`' + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /mail-accounts: + get: + operationId: list_mail_accounts + tags: [Mail] + summary: List the current user's mail accounts (`?all=true` for admins) + parameters: + - { in: query, name: all, required: false, schema: { type: string, enum: ['true'] } } + responses: + '200': + description: List + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/MailAccount' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + post: + operationId: create_mail_account + tags: [Mail] + summary: Create a mail account (password returned ONCE) + description: >- + Only on domains that can send. Role local parts + (postmaster, abuse, hostmaster, webmaster, root, admin, security, + noreply, dmarc) and `quotaMb` are admin-only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [externalDomainId, localPart] + properties: + externalDomainId: { type: integer } + localPart: { type: string, maxLength: 64 } + description: { type: string, maxLength: 255, nullable: true } + quotaMb: { type: integer, description: Admin-only } + responses: + '201': + description: Created with one-time `password` and `connection` info + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/MailAccountSecret' } } } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { description: 'Reserved local part or admin-only field (codes: reserved_local_part, admin_required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'Domain not found (code: domain_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: 'Address already exists (code: address_taken)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '422': { description: 'Domain cannot send (code: domain_cannot_send)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /mail-accounts/domains: + get: + operationId: list_mail_domains + tags: [Mail] + summary: Domains mail accounts can be created on (enabled + DNS verified) + responses: + '200': + description: List of `{ id, name }` + /mail-accounts/{id}: + parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }] + get: + operationId: get_mail_account + tags: [Mail] + summary: Get a mail account (owner or admin) + responses: + '200': + description: Item + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/MailAccount' } } } + '404': { $ref: '#/components/responses/NotFound' } + patch: + operationId: update_mail_account + tags: [Mail] + summary: Update a mail account (quota/transfer admin-only) + requestBody: + content: + application/json: + schema: + type: object + properties: + description: { type: string, maxLength: 255, nullable: true } + enabled: { type: boolean, description: Disabling locks the account out instantly } + unsubscribeHeaders: { type: boolean } + quotaMb: { type: integer, description: Admin-only } + username: { type: string, description: 'Admin-only ownership transfer — target username' } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/MailAccount' } } } + '403': { description: 'Admin-only field (code: admin_required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } + '422': { description: 'Transfer target inactive (code: user_inactive)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + delete: + operationId: delete_mail_account + tags: [Mail] + summary: Delete a mail account (owner or admin) + responses: + '204': { description: Deleted } + '404': { $ref: '#/components/responses/NotFound' } + /mail-accounts/{id}/rotate-password: + parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }] + post: + operationId: rotate_mail_account_password + tags: [Mail] + summary: Rotate the password (owner or admin; returned ONCE) + responses: + '200': + description: Account with one-time `password` and `connection` info + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/MailAccountSecret' } } } + '404': { $ref: '#/components/responses/NotFound' } + /mail-accounts/{id}/suppressions: + parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }] + get: + operationId: list_mail_account_suppressions + tags: [Mail] + summary: Recipients who unsubscribed from this account (owner or admin) + responses: + '200': + description: List + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/MailSuppression' } } } } + '404': { $ref: '#/components/responses/NotFound' } + /mail-accounts/{id}/suppressions/{suppressionId}: + parameters: + - { in: path, name: id, required: true, schema: { type: string, format: uuid } } + - { in: path, name: suppressionId, required: true, schema: { type: integer } } + delete: + operationId: delete_mail_account_suppression + tags: [Mail] + summary: Remove a suppression (admin only — owners cannot re-subscribe people) + responses: + '204': { description: Removed } + '403': { description: 'Not an admin (code: admin_required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } + + /mail/host: + get: + operationId: get_mail_host + tags: [Mail] + summary: Current mail-host claim holder, mail IP, and hostname (admin) + responses: + '200': { description: 'Holder agent (or null), mailIp, mailHostname, selfManagedSiteId' } + '403': { $ref: '#/components/responses/Forbidden' } + /mail/host/release: + post: + operationId: release_mail_host + tags: [Mail] + summary: Release the mail-host claim (admin) — next qualifying check-in takes it + responses: + '200': { description: '`{ released: boolean }`' } + '403': { $ref: '#/components/responses/Forbidden' } + /mail/ptr: + get: + operationId: get_mail_ptr + tags: [Mail] + summary: PTR suggestion for the mail_hostname setting (admin) + responses: + '200': { description: '`{ mailIp, ptr, forwardConfirmed, mailHostname, suggestion }`' } + '403': { $ref: '#/components/responses/Forbidden' } + + /agents/{id}/api-key-pin: + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + delete: + operationId: delete_agent_api_key_pin + tags: [Agents] + summary: Clear a remote agent's API-key pin (admin) — it re-pins on next check-in + responses: + '204': { description: Cleared } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + /users: get: operationId: list_users diff --git a/create-a-container/resources/mailaccounts/__tests__/service.test.js b/create-a-container/resources/mailaccounts/__tests__/service.test.js new file mode 100644 index 00000000..94ea1e89 --- /dev/null +++ b/create-a-container/resources/mailaccounts/__tests__/service.test.js @@ -0,0 +1,153 @@ +/** + * Unit tests for the mail-accounts service — repository mocked per the MVC + * manifesto. Covers the #67 guardrails: one-time passwords, Argon2id p=1 + * hashes, reserved local parts, the domain can-send gate, and admin-only + * quota/transfer/suppression-removal. + */ + +jest.mock('../repository'); + +const repo = require('../repository'); +const svc = require('../service'); + +const user = { uidNumber: 3001, uid: 'alice', status: 'active' }; +const admin = { uidNumber: 3000, uid: 'root-admin', status: 'active' }; +const sendableDomain = { id: 1, name: 'example.com', mailEnabled: true, mailDnsVerified: true }; +const disabledDomain = { id: 2, name: 'off.example', mailEnabled: false, mailDnsVerified: false }; + +const session = { uid: 'alice', isAdmin: false }; +const adminSession = { uid: 'root-admin', isAdmin: true }; + +function accountRow(overrides = {}) { + return { + id: '7d9fb37e-0000-4000-8000-000000000001', + uidNumber: user.uidNumber, + localPart: 'app', + domain: sendableDomain, + owner: user, + quotaBytes: 1024 * 1024 * 1024, + ...overrides, + }; +} + +beforeEach(() => { + jest.resetAllMocks(); + repo.findUserByUid.mockImplementation(async (uid) => { + if (uid === 'alice') return user; + if (uid === 'root-admin') return admin; + return null; + }); + repo.getSetting.mockResolvedValue(null); + repo.findDomain.mockResolvedValue(sendableDomain); + repo.findByAddress.mockResolvedValue(null); + repo.create.mockImplementation(async (fields) => accountRow(fields)); + repo.update.mockImplementation(async (account, fields) => ({ ...account, ...fields })); +}); + +describe('createAccount', () => { + test('returns the password once and stores only an Argon2id p=1 hash', async () => { + const { account, password, connection } = await svc.createAccount(session, { + externalDomainId: 1, + localPart: 'app', + }); + expect(password).toHaveLength(43); // 32 bytes base64url + const stored = repo.create.mock.calls[0][0]; + expect(stored.passwordHash).toMatch(/^\$argon2id\$/); + expect(stored.passwordHash).toMatch(/p=1[,$]/); + expect(Object.values(stored)).not.toContain(password); + // Default quota applies when no setting and no quotaMb given. + expect(stored.quotaBytes).toBe(1024 * 1024 * 1024); + expect(account.uidNumber).toBe(user.uidNumber); + expect(connection.username).toBe('app@example.com'); + expect(connection.smtp.ports).toEqual([587, 465]); + }); + + test('rejects domains that cannot send', async () => { + repo.findDomain.mockResolvedValue(disabledDomain); + await expect(svc.createAccount(session, { externalDomainId: 2, localPart: 'app' })) + .rejects.toMatchObject({ status: 422, code: 'domain_cannot_send' }); + }); + + test('reserves role local parts for admins', async () => { + await expect(svc.createAccount(session, { externalDomainId: 1, localPart: 'postmaster' })) + .rejects.toMatchObject({ status: 403, code: 'reserved_local_part' }); + await expect(svc.createAccount(adminSession, { externalDomainId: 1, localPart: 'postmaster' })) + .resolves.toBeTruthy(); + }); + + test('rejects duplicate addresses with 409', async () => { + repo.findByAddress.mockResolvedValue(accountRow()); + await expect(svc.createAccount(session, { externalDomainId: 1, localPart: 'app' })) + .rejects.toMatchObject({ status: 409, code: 'address_taken' }); + }); + + test('only admins can set a quota at create', async () => { + await expect(svc.createAccount(session, { externalDomainId: 1, localPart: 'app', quotaMb: 10 })) + .rejects.toMatchObject({ status: 403, code: 'admin_required' }); + await svc.createAccount(adminSession, { externalDomainId: 1, localPart: 'app', quotaMb: 10 }); + expect(repo.create.mock.calls[0][0].quotaBytes).toBe(10 * 1024 * 1024); + }); +}); + +describe('updateAccount', () => { + test('owner can toggle enabled/unsubscribeHeaders but not quota or transfer', async () => { + repo.findById.mockResolvedValue(accountRow()); + await svc.updateAccount(session, 'x-id', { enabled: false, unsubscribeHeaders: false }); + expect(repo.update.mock.calls[0][1]).toEqual({ enabled: false, unsubscribeHeaders: false }); + + await expect(svc.updateAccount(session, 'x-id', { quotaMb: 5 })) + .rejects.toMatchObject({ status: 403, code: 'admin_required' }); + await expect(svc.updateAccount(session, 'x-id', { username: 'bob' })) + .rejects.toMatchObject({ status: 403, code: 'admin_required' }); + }); + + test('admin transfer resolves the target user and rejects inactive users', async () => { + repo.findById.mockResolvedValue(accountRow()); + repo.findUserByUid.mockImplementation(async (uid) => { + if (uid === 'root-admin') return admin; + if (uid === 'bob') return { uidNumber: 3002, uid: 'bob', status: 'active' }; + if (uid === 'carol') return { uidNumber: 3003, uid: 'carol', status: 'disabled' }; + return null; + }); + await svc.updateAccount(adminSession, 'x-id', { username: 'bob' }); + expect(repo.update.mock.calls[0][1]).toEqual({ uidNumber: 3002 }); + + await expect(svc.updateAccount(adminSession, 'x-id', { username: 'carol' })) + .rejects.toMatchObject({ status: 422, code: 'user_inactive' }); + await expect(svc.updateAccount(adminSession, 'x-id', { username: 'nobody' })) + .rejects.toMatchObject({ status: 404, code: 'user_not_found' }); + }); + + test('non-owner non-admin gets a 404 (existence hidden)', async () => { + repo.findById.mockResolvedValue(accountRow({ uidNumber: 9999 })); + await expect(svc.updateAccount(session, 'x-id', { enabled: false })) + .rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('rotatePassword', () => { + test('returns a fresh password and stores a new hash + lastRotatedAt', async () => { + repo.findById.mockResolvedValue(accountRow()); + const { password } = await svc.rotatePassword(session, 'x-id'); + expect(password).toHaveLength(43); + const fields = repo.update.mock.calls[0][1]; + expect(fields.passwordHash).toMatch(/^\$argon2id\$/); + expect(fields.lastRotatedAt).toBeInstanceOf(Date); + }); +}); + +describe('suppressions', () => { + test('owner can list but only admins can remove', async () => { + repo.findById.mockResolvedValue(accountRow()); + repo.findSuppressions.mockResolvedValue([]); + await expect(svc.listSuppressions(session, 'x-id')).resolves.toEqual([]); + + await expect(svc.removeSuppression(session, 'x-id', 1)) + .rejects.toMatchObject({ status: 403, code: 'admin_required' }); + + repo.findSuppression.mockResolvedValue({ id: 1 }); + repo.destroySuppression.mockResolvedValue(); + await svc.removeSuppression(adminSession, 'x-id', 1); + expect(repo.destroySuppression).toHaveBeenCalled(); + }); +}); diff --git a/create-a-container/resources/mailaccounts/controller.js b/create-a-container/resources/mailaccounts/controller.js new file mode 100644 index 00000000..13e6418f --- /dev/null +++ b/create-a-container/resources/mailaccounts/controller.js @@ -0,0 +1,80 @@ +const svc = require('./service'); +const { serializeMailAccount, serializeSuppression } = require('./serializer'); +const { asyncHandler, ok, created, noContent } = require('../../middlewares/api'); + +const PASSWORD_WARNING = 'Save this password — it will not be shown again.'; + +function sessionOf(req) { + return { uid: req.session.user, isAdmin: !!req.session.isAdmin }; +} + +const list = asyncHandler(async (req, res) => { + const accounts = await svc.listAccounts(sessionOf(req), req.validated.query); + return ok(res, accounts.map(serializeMailAccount)); +}); + +const listDomains = asyncHandler(async (req, res) => { + const domains = await svc.listSendableDomains(sessionOf(req)); + return ok(res, domains.map((d) => ({ id: d.id, name: d.name }))); +}); + +const get = asyncHandler(async (req, res) => { + const account = await svc.getAccount(sessionOf(req), req.validated.params.id); + return ok(res, serializeMailAccount(account)); +}); + +const create = asyncHandler(async (req, res) => { + const { account, password, connection } = await svc.createAccount(sessionOf(req), req.validated.body); + return created(res, { + ...serializeMailAccount(account), + password, + connection, + warning: PASSWORD_WARNING, + }); +}); + +const update = asyncHandler(async (req, res) => { + const account = await svc.updateAccount(sessionOf(req), req.validated.params.id, req.validated.body); + return ok(res, serializeMailAccount(account)); +}); + +const rotatePassword = asyncHandler(async (req, res) => { + const { account, password, connection } = await svc.rotatePassword(sessionOf(req), req.validated.params.id); + return ok(res, { + ...serializeMailAccount(account), + password, + connection, + warning: PASSWORD_WARNING, + }); +}); + +const remove = asyncHandler(async (req, res) => { + await svc.deleteAccount(sessionOf(req), req.validated.params.id); + return noContent(res); +}); + +const listSuppressions = asyncHandler(async (req, res) => { + const suppressions = await svc.listSuppressions(sessionOf(req), req.validated.params.id); + return ok(res, suppressions.map(serializeSuppression)); +}); + +const removeSuppression = asyncHandler(async (req, res) => { + await svc.removeSuppression( + sessionOf(req), + req.validated.params.id, + req.validated.params.suppressionId, + ); + return noContent(res); +}); + +module.exports = { + list, + listDomains, + get, + create, + update, + rotatePassword, + remove, + listSuppressions, + removeSuppression, +}; diff --git a/create-a-container/resources/mailaccounts/repository.js b/create-a-container/resources/mailaccounts/repository.js new file mode 100644 index 00000000..ee52d56a --- /dev/null +++ b/create-a-container/resources/mailaccounts/repository.js @@ -0,0 +1,91 @@ +const { MailAccount, MailSuppression, ExternalDomain, User, Setting } = require('../../models'); + +// Always loaded with the owning user and domain (incl. mail gates) so the +// serializer can compose the address and send/receive state. +const INCLUDES = [ + { + model: ExternalDomain, + as: 'domain', + attributes: ['id', 'name', 'mailEnabled', 'mailDnsVerified', 'mailMxVerified'], + }, + { model: User, as: 'owner', attributes: ['uidNumber', 'uid'] }, +]; + +async function findAll(where = {}) { + return MailAccount.findAll({ where, include: INCLUDES, order: [['createdAt', 'DESC']] }); +} + +async function findById(id) { + return MailAccount.findByPk(id, { include: INCLUDES }); +} + +async function findByAddress(externalDomainId, localPart) { + return MailAccount.findOne({ where: { externalDomainId, localPart } }); +} + +async function create(fields) { + const account = await MailAccount.create(fields); + return findById(account.id); +} + +async function update(account, fields) { + await account.update(fields); + return findById(account.id); +} + +async function destroy(account) { + return account.destroy(); +} + +async function findDomain(id) { + return ExternalDomain.findByPk(id); +} + +async function findSendableDomains() { + return ExternalDomain.findAll({ + where: { mailEnabled: true, mailDnsVerified: true }, + attributes: ['id', 'name'], + order: [['name', 'ASC']], + }); +} + +async function findSuppressions(mailAccountId) { + return MailSuppression.findAll({ + where: { mailAccountId }, + order: [['createdAt', 'DESC']], + }); +} + +async function findSuppression(mailAccountId, suppressionId) { + return MailSuppression.findOne({ where: { id: suppressionId, mailAccountId } }); +} + +async function destroySuppression(suppression) { + return suppression.destroy(); +} + +// Owner lookup by session uid (manifesto: cross-resource access moves behind +// the users service once resources/users/ exists). +async function findUserByUid(uid) { + return User.findOne({ where: { uid } }); +} + +async function getSetting(key) { + return Setting.get(key); +} + +module.exports = { + findAll, + findById, + findByAddress, + create, + update, + destroy, + findDomain, + findSendableDomains, + findSuppressions, + findSuppression, + destroySuppression, + findUserByUid, + getSetting, +}; diff --git a/create-a-container/resources/mailaccounts/router.js b/create-a-container/resources/mailaccounts/router.js new file mode 100644 index 00000000..e1c81079 --- /dev/null +++ b/create-a-container/resources/mailaccounts/router.js @@ -0,0 +1,33 @@ +/** + * /api/v1/mail-accounts — email service accounts (issue #67). + * Wiring only; see controller/service/repository per the MVC manifesto. + */ + +const express = require('express'); +const { apiAuth } = require('../../middlewares/api'); +const { validate } = require('../../middlewares/validate'); +const { createMailAccount, updateMailAccount, idParam, suppressionParams, listQuery } = require('./validator'); +const ctrl = require('./controller'); + +const router = express.Router(); + +router.use(apiAuth); + +// `/domains` must precede `/:id`. +router.get('/domains', ctrl.listDomains); + +router.get('/', validate({ query: listQuery }), ctrl.list); +router.post('/', validate(createMailAccount), ctrl.create); +router.get('/:id', validate({ params: idParam }), ctrl.get); +router.patch('/:id', validate({ params: idParam, body: updateMailAccount }), ctrl.update); +router.post('/:id/rotate-password', validate({ params: idParam }), ctrl.rotatePassword); +router.delete('/:id', validate({ params: idParam }), ctrl.remove); + +router.get('/:id/suppressions', validate({ params: idParam }), ctrl.listSuppressions); +router.delete( + '/:id/suppressions/:suppressionId', + validate({ params: suppressionParams }), + ctrl.removeSuppression, +); + +module.exports = router; diff --git a/create-a-container/resources/mailaccounts/serializer.js b/create-a-container/resources/mailaccounts/serializer.js new file mode 100644 index 00000000..f095a0b5 --- /dev/null +++ b/create-a-container/resources/mailaccounts/serializer.js @@ -0,0 +1,38 @@ +const MB = 1024 * 1024; + +/** Never serialized: passwordHash. BIGINT comes back as a string on Postgres. */ +function serializeMailAccount(a) { + const domain = a.domain + ? { + id: a.domain.id, + name: a.domain.name, + canSend: !!(a.domain.mailEnabled && a.domain.mailDnsVerified), + canReceive: !!(a.domain.mailEnabled && a.domain.mailMxVerified), + } + : null; + return { + id: a.id, + address: domain ? `${a.localPart}@${domain.name}` : null, + localPart: a.localPart, + domain, + owner: a.owner ? a.owner.uid : null, + description: a.description, + enabled: a.enabled, + quotaMb: Math.round(Number(a.quotaBytes) / MB), + unsubscribeHeaders: a.unsubscribeHeaders, + lastRotatedAt: a.lastRotatedAt, + createdAt: a.createdAt, + updatedAt: a.updatedAt, + }; +} + +function serializeSuppression(s) { + return { + id: s.id, + recipient: s.recipient, + source: s.source, + createdAt: s.createdAt, + }; +} + +module.exports = { serializeMailAccount, serializeSuppression }; diff --git a/create-a-container/resources/mailaccounts/service.js b/create-a-container/resources/mailaccounts/service.js new file mode 100644 index 00000000..d1bcc1fc --- /dev/null +++ b/create-a-container/resources/mailaccounts/service.js @@ -0,0 +1,169 @@ +/** + * Mail accounts business logic (issue #67). + * + * Accounts exist only on domains that can send (mailEnabled + DNS verified). + * Passwords are generated server-side, returned exactly once, and stored as + * Argon2id (p=1) hashes that Dovecot verifies live through mail_accounts_v. + */ + +const repo = require('./repository'); +const { ApiError } = require('../../middlewares/api'); +const { + DEFAULT_QUOTA_MB, + generateMailPassword, + hashMailPassword, + isReservedLocalPart, +} = require('../../utils/mail-account'); + +const MB = 1024 * 1024; + +async function requireUser(session) { + const user = await repo.findUserByUid(session.uid); + if (!user) throw new ApiError(401, 'unauthorized', 'Session user not found'); + return user; +} + +function domainCanSend(domain) { + return !!(domain.mailEnabled && domain.mailDnsVerified); +} + +/** Owner or admin may see the account; others get a 404 (existence hidden). */ +async function loadAccountFor(session, id, user) { + const account = await repo.findById(id); + if (!account || (!session.isAdmin && account.uidNumber !== user.uidNumber)) { + throw new ApiError(404, 'not_found', 'Mail account not found'); + } + return account; +} + +/** SMTP/IMAP client settings returned alongside one-time passwords. */ +async function connectionInfo(account) { + const mailHostname = await repo.getSetting('mail_hostname'); + const address = `${account.localPart}@${account.domain.name}`; + return { + host: mailHostname || account.domain.name, + username: address, + smtp: { ports: [587, 465] }, + imap: { ports: [993, 143] }, + }; +} + +async function listAccounts(session, { all } = {}) { + const user = await requireUser(session); + if (all && !session.isAdmin) { + throw new ApiError(403, 'forbidden', 'Admin access required to list all accounts'); + } + return repo.findAll(all && session.isAdmin ? {} : { uidNumber: user.uidNumber }); +} + +async function getAccount(session, id) { + const user = await requireUser(session); + return loadAccountFor(session, id, user); +} + +async function listSendableDomains(session) { + await requireUser(session); + return repo.findSendableDomains(); +} + +/** Returns { account, password, connection } — password shown exactly once. */ +async function createAccount(session, { externalDomainId, localPart, description, quotaMb }) { + const user = await requireUser(session); + + const domain = await repo.findDomain(externalDomainId); + if (!domain) throw new ApiError(404, 'domain_not_found', 'External domain not found'); + if (!domainCanSend(domain)) { + throw new ApiError(422, 'domain_cannot_send', 'Mail is not enabled (or DNS is not verified) for this domain'); + } + if (isReservedLocalPart(localPart) && !session.isAdmin) { + throw new ApiError(403, 'reserved_local_part', `"${localPart}" is reserved for administrators`); + } + if (quotaMb !== undefined && !session.isAdmin) { + throw new ApiError(403, 'admin_required', 'Only admins can set a quota'); + } + if (await repo.findByAddress(externalDomainId, localPart)) { + throw new ApiError(409, 'address_taken', 'That address already exists'); + } + + const defaultQuotaMb = parseInt(await repo.getSetting('mail_default_quota_mb'), 10) || DEFAULT_QUOTA_MB; + const password = generateMailPassword(); + const account = await repo.create({ + uidNumber: user.uidNumber, + externalDomainId, + localPart, + description: description || null, + passwordHash: await hashMailPassword(password), + quotaBytes: (quotaMb ?? defaultQuotaMb) * MB, + }); + return { account, password, connection: await connectionInfo(account) }; +} + +async function updateAccount(session, id, body) { + const user = await requireUser(session); + const account = await loadAccountFor(session, id, user); + + if ((body.quotaMb !== undefined || body.username !== undefined) && !session.isAdmin) { + throw new ApiError(403, 'admin_required', 'Only admins can change quota or transfer ownership'); + } + + const fields = {}; + if (body.description !== undefined) fields.description = body.description; + if (body.enabled !== undefined) fields.enabled = body.enabled; + if (body.unsubscribeHeaders !== undefined) fields.unsubscribeHeaders = body.unsubscribeHeaders; + if (body.quotaMb !== undefined) fields.quotaBytes = body.quotaMb * MB; + if (body.username !== undefined) { + const target = await repo.findUserByUid(body.username); + if (!target) throw new ApiError(404, 'user_not_found', 'Target user not found'); + if (target.status !== 'active') throw new ApiError(422, 'user_inactive', 'Target user is not active'); + fields.uidNumber = target.uidNumber; + } + return repo.update(account, fields); +} + +/** Returns { account, password, connection } — fresh password shown exactly once. */ +async function rotatePassword(session, id) { + const user = await requireUser(session); + const account = await loadAccountFor(session, id, user); + const password = generateMailPassword(); + const updated = await repo.update(account, { + passwordHash: await hashMailPassword(password), + lastRotatedAt: new Date(), + }); + return { account: updated, password, connection: await connectionInfo(updated) }; +} + +async function deleteAccount(session, id) { + const user = await requireUser(session); + const account = await loadAccountFor(session, id, user); + await repo.destroy(account); +} + +async function listSuppressions(session, id) { + const user = await requireUser(session); + await loadAccountFor(session, id, user); + return repo.findSuppressions(id); +} + +/** Admin-only: an app owner must not be able to quietly re-subscribe people. */ +async function removeSuppression(session, id, suppressionId) { + const user = await requireUser(session); + await loadAccountFor(session, id, user); + if (!session.isAdmin) { + throw new ApiError(403, 'admin_required', 'Only admins can remove suppressions'); + } + const suppression = await repo.findSuppression(id, suppressionId); + if (!suppression) throw new ApiError(404, 'not_found', 'Suppression not found'); + await repo.destroySuppression(suppression); +} + +module.exports = { + listAccounts, + getAccount, + listSendableDomains, + createAccount, + updateAccount, + rotatePassword, + deleteAccount, + listSuppressions, + removeSuppression, +}; diff --git a/create-a-container/resources/mailaccounts/validator.js b/create-a-container/resources/mailaccounts/validator.js new file mode 100644 index 00000000..94919496 --- /dev/null +++ b/create-a-container/resources/mailaccounts/validator.js @@ -0,0 +1,45 @@ +const { z } = require('zod'); + +// Conservative RFC 5321 dot-atom subset; stored lowercase. Alphanumeric +// runs joined by single separators — rejects consecutive dots like "a..b". +const localPart = z + .string() + .trim() + .toLowerCase() + .min(1) + .max(64) + .regex(/^[a-z0-9]+(?:[._+-][a-z0-9]+)*$/, 'invalid local part'); + +const createMailAccount = z.object({ + externalDomainId: z.coerce.number().int().positive(), + localPart, + description: z.string().max(255).nullish(), + // Admin-only (service enforces); non-admins get the default quota. + quotaMb: z.coerce.number().int().min(1).max(1048576).optional(), +}); + +const updateMailAccount = z.object({ + description: z.string().max(255).nullable().optional(), + enabled: z.boolean().optional(), + unsubscribeHeaders: z.boolean().optional(), + // Admin-only (service enforces). + quotaMb: z.coerce.number().int().min(1).max(1048576).optional(), + // Admin-only ownership transfer — target user's uid (username). + username: z.string().trim().min(1).max(255).optional(), +}); + +const idParam = z.object({ + id: z.uuid(), +}); + +const suppressionParams = z.object({ + id: z.uuid(), + suppressionId: z.coerce.number().int().positive(), +}); + +const listQuery = z.object({ + // `?all=true` — admins list every account instead of just their own. + all: z.literal('true').optional(), +}); + +module.exports = { createMailAccount, updateMailAccount, idParam, suppressionParams, listQuery }; diff --git a/create-a-container/routers/api/v1/agents.js b/create-a-container/routers/api/v1/agents.js index 3accd43e..6e8ba276 100644 --- a/create-a-container/routers/api/v1/agents.js +++ b/create-a-container/routers/api/v1/agents.js @@ -9,8 +9,12 @@ const express = require('express'); const { Agent, Site, Container, Volume } = require('../../../models'); -const { apiAuth, apiAdmin, localhostOrAdmin, asyncHandler, ok, fail } = require('../../../middlewares/api'); -const { buildAgentConfig, computeConfigEtag } = require('../../../utils/agent-config'); +const { apiAuth, apiAdmin, localhostOrAdmin, isLocalhostRequest, asyncHandler, ok, noContent, fail } = require('../../../middlewares/api'); +const { buildAgentConfig, buildMailSnapshot, computeConfigEtag } = require('../../../utils/agent-config'); +const { MAIL_SERVICE, processMailClaim } = require('../../../utils/mail-host'); + +const env = process.env.NODE_ENV || 'development'; +const dbConfig = require('../../../config/config.js')[env]; const router = express.Router(); @@ -53,7 +57,7 @@ async function applyVolumeResults(siteId, volumesResult) { } router.post('/', localhostOrAdmin, asyncHandler(async (req, res) => { - const { siteId, hostname, ipv4Address, services, volumes } = req.body || {}; + const { siteId, hostname, ipv4Address, services, volumes, enabledServices, missingBinaries } = req.body || {}; const parsedSiteId = typeof siteId === 'number' ? siteId : Number(siteId); if (!Number.isInteger(parsedSiteId) || !hostname || typeof hostname !== 'string') { return fail(res, 422, 'validation_failed', 'siteId and hostname are required'); @@ -61,22 +65,54 @@ router.post('/', localhostOrAdmin, asyncHandler(async (req, res) => { // Record the check-in. Skipped during bootstrap (the site row doesn't exist // yet) since the foreign key has nothing to point at. + let mailClaim = null; const site = await Site.findByPk(parsedSiteId); if (site) { const [agent] = await Agent.findOrCreate({ where: { siteId: parsedSiteId, hostname }, }); + const isLocal = isLocalhostRequest(req); + // Remote agents are pinned to the API key of their first check-in so a + // leaked lesser key can't impersonate an established agent (issue #67). + // Admins clear a pin via DELETE /agents/:id/api-key-pin. + if (!isLocal && agent.apiKeyId && req.apiKey && agent.apiKeyId !== req.apiKey.id) { + return fail(res, 403, 'agent_key_mismatch', 'This agent is pinned to a different API key'); + } await agent.update({ ipv4Address: ipv4Address || null, services: services || null, + enabledServices: Array.isArray(enabledServices) ? enabledServices : null, + missingBinaries: Array.isArray(missingBinaries) ? missingBinaries : null, + isLocal, + apiKeyId: agent.apiKeyId || (!isLocal && req.apiKey ? req.apiKey.id : null), lastCheckinAt: new Date(), }); // Transition Volume.status from the agent's per-volume directory results, // scoped to this site so a check-in can't touch another site's volumes. await applyVolumeResults(parsedSiteId, volumes); + // Mail-host claim: first agent reporting `mail` with everything installed + // wins; the rest see conflict (or unsupported on SQLite). + mailClaim = await processMailClaim(agent, { + wantsMail: Array.isArray(enabledServices) && enabledServices.includes(MAIL_SERVICE), + missingBinaries, + }); } const config = await buildAgentConfig(parsedSiteId); + if (mailClaim && mailClaim.status !== 'disabled') { + config.mailStatus = mailClaim.status; + // Only the claim holder receives the mail section (DKIM private keys, + // unsubscribe AES keys, relay credentials) — and never over plaintext: + // remote check-ins must arrive on TLS (req.secure honors X-Forwarded-Proto + // via trust proxy) or the secrets would cross the network readable. + if (mailClaim.status === 'holder') { + if (isLocalhostRequest(req) || req.secure) { + config.mail = await buildMailSnapshot(dbConfig); + } else { + config.mailStatus = 'insecure_transport'; + } + } + } // Manual conditional-request handling: Express's built-in ETag/fresh logic // (res.send + req.fresh) only produces 304s for GET/HEAD, and the check-in // is a POST. @@ -101,6 +137,11 @@ router.get('/', apiAuth, apiAdmin, asyncHandler(async (req, res) => { hostname: a.hostname, ipv4Address: a.ipv4Address, services: a.services, + enabledServices: a.enabledServices, + missingBinaries: a.missingBinaries, + mailHostSince: a.mailHostSince, + isLocal: a.isLocal, + hasApiKeyPin: !!a.apiKeyId, lastCheckinAt: a.lastCheckinAt, // Computed server-side so UI staleness judgments don't depend on the // client's clock. @@ -110,4 +151,13 @@ router.get('/', apiAuth, apiAdmin, asyncHandler(async (req, res) => { }))); })); +// Clear a remote agent's API-key pin so it can re-pin on its next check-in +// (e.g. after rotating the agent's key). +router.delete('/:id/api-key-pin', apiAuth, apiAdmin, asyncHandler(async (req, res) => { + const agent = await Agent.findByPk(req.params.id); + if (!agent) return fail(res, 404, 'not_found', 'Agent not found'); + await agent.update({ apiKeyId: null }); + return noContent(res); +})); + module.exports = router; diff --git a/create-a-container/routers/api/v1/external-domains.js b/create-a-container/routers/api/v1/external-domains.js index bd1527b0..467fa4f0 100644 --- a/create-a-container/routers/api/v1/external-domains.js +++ b/create-a-container/routers/api/v1/external-domains.js @@ -1,12 +1,16 @@ /** - * /api/v1/external-domains — admin-only CRUD. + * /api/v1/external-domains — admin-only CRUD + per-domain mail enablement. * The Cloudflare API key is write-only: never returned in any response. + * Mail flags are read-only here — they change only via the /mail endpoints. */ const express = require('express'); -const { ExternalDomain, Site } = require('../../../models'); +const { ExternalDomain, Site, DkimKey, MailAccount, Setting, sequelize } = require('../../../models'); const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } = require('../../../middlewares/api'); +const { generateDkimKeyPair } = require('../../../utils/dkim'); +const { buildMailDnsRecords, checkMailDns } = require('../../../utils/mail-dns'); +const { getMailIp } = require('../../../utils/mail-host'); const router = express.Router(); @@ -23,6 +27,11 @@ function serialize(d) { site: d.site ? { id: d.site.id, name: d.site.name } : null, authServer: d.authServer, hasCloudflareApiKey: !!d.cloudflareApiKey, + // Read-only mail state (issue #67) — mutate via the /mail endpoints. + mailEnabled: d.mailEnabled, + mailDnsVerified: d.mailDnsVerified, + mailMxVerified: d.mailMxVerified, + mailDnsCheckedAt: d.mailDnsCheckedAt, }; } @@ -53,14 +62,22 @@ router.post( asyncHandler(async (req, res) => { const { name, acmeEmail, acmeDirectoryUrl, cloudflareApiEmail, cloudflareApiKey, siteId, authServer } = req.body || {}; - const d = await ExternalDomain.create({ - name, - acmeEmail: acmeEmail || null, - acmeDirectoryUrl: acmeDirectoryUrl || null, - cloudflareApiEmail: cloudflareApiEmail || null, - cloudflareApiKey: cloudflareApiKey || null, - siteId: siteId || null, - authServer: authServer || null, + // Every domain gets a DKIM key at create so the DNS records can be + // published before mail is ever enabled (existing domains were + // backfilled by migration 20261008000007). One transaction — a failed + // key insert must not leave a keyless domain behind. + const d = await sequelize.transaction(async (transaction) => { + const domain = await ExternalDomain.create({ + name, + acmeEmail: acmeEmail || null, + acmeDirectoryUrl: acmeDirectoryUrl || null, + cloudflareApiEmail: cloudflareApiEmail || null, + cloudflareApiKey: cloudflareApiKey || null, + siteId: siteId || null, + authServer: authServer || null, + }, { transaction }); + await DkimKey.create({ externalDomainId: domain.id, ...generateDkimKeyPair() }, { transaction }); + return domain; }); return created(res, serialize(d)); }), @@ -99,4 +116,131 @@ router.delete( }), ); +// --------------------------------------------------------------------------- +// Mail enablement (issue #67). Send gate = mailEnabled && mailDnsVerified; +// receive gate = mailEnabled && mailMxVerified. Re-verification is manual. +// --------------------------------------------------------------------------- + +async function loadDomain(id) { + const d = await ExternalDomain.findByPk(id); + if (!d) throw new ApiError(404, 'not_found', 'External domain not found'); + return d; +} + +/** Everything buildMailDnsRecords/checkMailDns need for one domain. */ +async function mailDnsContext(d) { + const settings = await Setting.getMultiple([ + 'mail_hostname', 'mail_spf_include', 'mail_dns_check_resolvers', + ]); + const dkimKey = await DkimKey.findOne({ + where: { externalDomainId: d.id, status: 'active' }, + order: [['createdAt', 'DESC']], + }); + return { + domain: d.name, + mailIp: await getMailIp(), + mailHostname: settings.mail_hostname || null, + spfInclude: settings.mail_spf_include || null, + dkimKey: dkimKey ? { selector: dkimKey.selector, publicKey: dkimKey.publicKey } : null, + resolvers: (settings.mail_dns_check_resolvers || '') + .split(',').map((s) => s.trim()).filter(Boolean), + }; +} + +/** Run Check DNS, persist the outcome, and flip the verification gates. */ +async function runDnsCheck(d) { + const context = await mailDnsContext(d); + const result = await checkMailDns(context); + const accounts = await MailAccount.findAll({ + where: { externalDomainId: d.id, localPart: ['postmaster', 'abuse'] }, + attributes: ['localPart'], + }); + for (const role of ['postmaster', 'abuse']) { + if (!accounts.some((a) => a.localPart === role)) { + result.warnings.push(`No ${role}@${d.name} mailbox exists yet (admins create role mailboxes).`); + } + } + await d.update({ + mailDnsCheckedAt: new Date(), + mailDnsCheckResult: result, + mailDnsVerified: result.spf.pass && result.dkim.pass && result.dmarc.pass, + mailMxVerified: result.mx.pass, + }); + return result; +} + +router.get( + '/:id/mail/dns', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + const context = await mailDnsContext(d); + return ok(res, { + records: buildMailDnsRecords(context), + mailEnabled: d.mailEnabled, + mailDnsVerified: d.mailDnsVerified, + mailMxVerified: d.mailMxVerified, + lastCheck: d.mailDnsCheckedAt + ? { checkedAt: d.mailDnsCheckedAt, result: d.mailDnsCheckResult } + : null, + }); + }), +); + +router.post( + '/:id/mail/check-dns', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + const result = await runDnsCheck(d); + return ok(res, { ...serialize(d), check: result }); + }), +); + +router.post( + '/:id/mail/enable', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + const result = await runDnsCheck(d); + // SPF/DKIM/DMARC are hard requirements to send; MX failure only means + // the domain can't receive yet, so it just warns. + if (!d.mailDnsVerified) { + throw new ApiError(422, 'dns_not_configured', + 'SPF, DKIM, or DMARC records are missing or wrong — publish the records from GET …/mail/dns and re-check'); + } + await d.update({ mailEnabled: true }); + return ok(res, { ...serialize(d), check: result }); + }), +); + +router.post( + '/:id/mail/disable', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + // Keys and config are kept; accounts stop authenticating immediately + // because the SQL views gate on mailEnabled. + await d.update({ mailEnabled: false }); + return ok(res, serialize(d)); + }), +); + +// Self-managed MTA export: the active DKIM private key. Deliberately noisy +// in the logs — private-key reads should be rare, intentional events. +router.get( + '/:id/mail/dkim', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + const key = await DkimKey.findOne({ + where: { externalDomainId: d.id, status: 'active' }, + 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}`); + return ok(res, { + domain: d.name, + selector: key.selector, + privateKey: key.privateKey, + publicKey: key.publicKey, + }); + }), +); + module.exports = router; diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index 3e45093a..c27f2308 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -91,6 +91,8 @@ router.use('/external-domains', require('./external-domains')); router.use('/groups', require('./groups')); router.use('/users', require('./users')); router.use('/apikeys', require('../../../resources/apikeys/router')); +router.use('/mail-accounts', require('../../../resources/mailaccounts/router')); +router.use('/mail', require('./mail')); router.use('/settings', require('./settings')); router.use('/jobs', require('./jobs')); router.use('/resource-requests', require('./resource-requests')); diff --git a/create-a-container/routers/api/v1/mail.js b/create-a-container/routers/api/v1/mail.js new file mode 100644 index 00000000..5185e45b --- /dev/null +++ b/create-a-container/routers/api/v1/mail.js @@ -0,0 +1,70 @@ +/** + * /api/v1/mail — admin mail-host management (issue #67). + * + * GET /host who holds the mail-host claim + the mail IP + * POST /host/release release the claim (next qualifying check-in takes it) + * GET /ptr PTR suggestion for the mail_hostname setting + */ + +const express = require('express'); +const { Setting } = require('../../../models'); +const { apiAuth, apiAdmin, asyncHandler, ok } = require('../../../middlewares/api'); +const { getMailHostAgent, getMailIp, releaseMailHost } = require('../../../utils/mail-host'); +const { lookupPtr } = require('../../../utils/mail-dns'); + +const router = express.Router(); + +router.use(apiAuth, apiAdmin); + +router.get( + '/host', + asyncHandler(async (_req, res) => { + const holder = await getMailHostAgent(); + const settings = await Setting.getMultiple(['mail_hostname', 'mail_self_managed_site_id']); + return ok(res, { + agent: holder + ? { + id: holder.id, + siteId: holder.siteId, + siteName: holder.site?.name || null, + hostname: holder.hostname, + mailHostSince: holder.mailHostSince, + enabledServices: holder.enabledServices, + missingBinaries: holder.missingBinaries, + } + : null, + mailIp: await getMailIp(), + mailHostname: settings.mail_hostname || null, + selfManagedSiteId: parseInt(settings.mail_self_managed_site_id, 10) || null, + }); + }), +); + +router.post( + '/host/release', + asyncHandler(async (_req, res) => { + const released = await releaseMailHost(); + return ok(res, { released: !!released }); + }), +); + +router.get( + '/ptr', + asyncHandler(async (_req, res) => { + const mailIp = await getMailIp(); + const settings = await Setting.getMultiple(['mail_hostname', 'mail_dns_check_resolvers']); + const resolvers = (settings.mail_dns_check_resolvers || '') + .split(',').map((s) => s.trim()).filter(Boolean); + const ptr = mailIp ? await lookupPtr({ ip: mailIp, resolvers }) : { name: null, forwardConfirmed: false }; + return ok(res, { + mailIp, + ptr: ptr.name, + forwardConfirmed: ptr.forwardConfirmed, + mailHostname: settings.mail_hostname || null, + // Suggest the forward-confirmed PTR name as mail_hostname. + suggestion: ptr.forwardConfirmed ? ptr.name : null, + }); + }), +); + +module.exports = router; diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index bbe3e3dc..28cd8854 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -4,7 +4,7 @@ const express = require('express'); const { Setting } = require('../../../models'); -const { apiAuth, apiAdmin, asyncHandler, ok } = require('../../../middlewares/api'); +const { apiAuth, apiAdmin, asyncHandler, ok, fail } = require('../../../middlewares/api'); const router = express.Router(); @@ -18,6 +18,17 @@ const KEYS = [ 'netbox_token', 'banner_message', 'usage_psi_probe_limit', + // Mail service (issue #67) + 'mail_hostname', + 'mail_relayhost', + 'mail_relayhost_username', + 'mail_relayhost_password', + 'mail_spf_include', + 'mail_dns_check_resolvers', + 'mail_db_host', + 'mail_self_managed_site_id', + 'mail_default_quota_mb', + 'mail_message_size_limit_mb', ]; router.get( @@ -38,6 +49,16 @@ router.get( netboxToken: settings.netbox_token || '', bannerMessage: settings.banner_message || '', usagePsiProbeLimit: settings.usage_psi_probe_limit || '', + mailHostname: settings.mail_hostname || '', + mailRelayhost: settings.mail_relayhost || '', + mailRelayhostUsername: settings.mail_relayhost_username || '', + mailRelayhostPassword: settings.mail_relayhost_password || '', + mailSpfInclude: settings.mail_spf_include || '', + mailDnsCheckResolvers: settings.mail_dns_check_resolvers || '', + mailDbHost: settings.mail_db_host || '', + mailSelfManagedSiteId: settings.mail_self_managed_site_id || '', + mailDefaultQuotaMb: settings.mail_default_quota_mb || '', + mailMessageSizeLimitMb: settings.mail_message_size_limit_mb || '', }); }), ); @@ -53,8 +74,23 @@ router.put( netboxToken, bannerMessage, usagePsiProbeLimit, + mailHostname, + mailRelayhost, + mailRelayhostUsername, + mailRelayhostPassword, + mailSpfInclude, + mailDnsCheckResolvers, + mailDbHost, + mailSelfManagedSiteId, + mailDefaultQuotaMb, + mailMessageSizeLimitMb, } = req.body || {}; + // A relayhost sends from the relay's IPs, so SPF must delegate to it. + if ((mailRelayhost || '').trim() && !(mailSpfInclude || '').trim()) { + return fail(res, 422, 'validation_failed', 'mailSpfInclude is required when a relayhost is set'); + } + const envVars = []; if (Array.isArray(defaultContainerEnvVars)) { for (const e of defaultContainerEnvVars) { @@ -81,6 +117,21 @@ router.put( Number.isNaN(psiParsed) || psiParsed < 0 ? '' : String(psiParsed), ); + const positiveIntOrEmpty = (value) => { + const n = parseInt(String(value ?? '').trim(), 10); + return Number.isNaN(n) || n < 1 ? '' : String(n); + }; + await Setting.set('mail_hostname', (mailHostname || '').trim()); + await Setting.set('mail_relayhost', (mailRelayhost || '').trim()); + await Setting.set('mail_relayhost_username', (mailRelayhostUsername || '').trim()); + await Setting.set('mail_relayhost_password', mailRelayhostPassword || ''); + await Setting.set('mail_spf_include', (mailSpfInclude || '').trim()); + await Setting.set('mail_dns_check_resolvers', (mailDnsCheckResolvers || '').trim()); + await Setting.set('mail_db_host', (mailDbHost || '').trim()); + await Setting.set('mail_self_managed_site_id', positiveIntOrEmpty(mailSelfManagedSiteId)); + await Setting.set('mail_default_quota_mb', positiveIntOrEmpty(mailDefaultQuotaMb)); + await Setting.set('mail_message_size_limit_mb', positiveIntOrEmpty(mailMessageSizeLimitMb)); + return ok(res, { saved: true }); }), ); diff --git a/create-a-container/routers/unsubscribe.js b/create-a-container/routers/unsubscribe.js new file mode 100644 index 00000000..beeaea31 --- /dev/null +++ b/create-a-container/routers/unsubscribe.js @@ -0,0 +1,95 @@ +/** + * Public one-click unsubscribe endpoint (RFC 8058, issue #67). Mounted at + * /u OUTSIDE /api/v1 and before the CSRF guard: requests come from mail + * clients and recipients with no session, token, or credentials. Abuse + * control (rate limiting) belongs in the proxy, not in-app. + * + * GET /u/:token confirmation page — a GET NEVER unsubscribes, so link + * scanners that prefetch URLs can't trigger it. + * POST /u/:token body `List-Unsubscribe=One-Click` — verify, upsert the + * suppression, 200. Idempotent. + * + * Every failure mode (bad token, unknown kid, deleted account) returns the + * same generic 400 so the endpoint can't be used to probe for accounts or + * addresses. + */ + +const express = require('express'); +const { MailAccount, MailSuppression, MailUnsubscribeKey } = require('../models'); +const { asyncHandler } = require('../middlewares/api'); +const { verifyUnsubscribeToken } = require('../utils/unsubscribe-token'); + +const router = express.Router(); + +router.use(express.urlencoded({ extended: false })); + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function page(title, body) { + return ` + + + + + +${escapeHtml(title)} + + + +
${body}
+ +`; +} + +const GENERIC_400 = page( + 'Invalid link', + '

Invalid link

This unsubscribe link is invalid or has expired.

', +); + +/** Verify the token and load its account; null means "answer generic 400". */ +async function resolveToken(token) { + const keys = await MailUnsubscribeKey.verifiable(); + const payload = verifyUnsubscribeToken(token, keys); + if (!payload) return null; + const account = await MailAccount.findByPk(payload.accountId); + if (!account) return null; + return { account, recipient: payload.recipient }; +} + +router.get('/:token', asyncHandler(async (req, res) => { + const resolved = await resolveToken(req.params.token); + if (!resolved) return res.status(400).type('html').send(GENERIC_400); + const recipient = escapeHtml(resolved.recipient); + return res.type('html').send(page('Unsubscribe', ` +

Unsubscribe

+

Stop receiving email to ${recipient} from this sender?

+
+ + +
`)); +})); + +router.post('/:token', asyncHandler(async (req, res) => { + // RFC 8058 §3.2: the POST body must be List-Unsubscribe=One-Click. + if ((req.body || {})['List-Unsubscribe'] !== 'One-Click') { + return res.status(400).type('html').send(GENERIC_400); + } + const resolved = await resolveToken(req.params.token); + if (!resolved) return res.status(400).type('html').send(GENERIC_400); + await MailSuppression.findOrCreate({ + where: { mailAccountId: resolved.account.id, recipient: resolved.recipient }, + defaults: { source: 'one-click' }, + }); + return res.type('html').send(page('Unsubscribed', ` +

Unsubscribed

+

${escapeHtml(resolved.recipient)} will no longer receive email from this sender.

`)); +})); + +module.exports = router; diff --git a/create-a-container/utils/__tests__/mail-dns.test.js b/create-a-container/utils/__tests__/mail-dns.test.js new file mode 100644 index 00000000..eb2acfc0 --- /dev/null +++ b/create-a-container/utils/__tests__/mail-dns.test.js @@ -0,0 +1,140 @@ +/** + * Unit tests for utils/mail-dns.js — record building and the Check DNS + * pass/fail gates, with an injected fake resolver (no network). + */ + +const { + buildMailDnsRecords, + checkMailDns, + ipv4InCidr, + spfRecordPasses, + dkimRecordPublicKey, +} = require('../mail-dns'); + +const DKIM = { selector: 'os20261008', publicKey: 'MIIBIjANBgkq+TESTKEY' }; + +function fakeResolver({ txt = {}, mx = [], a = {}, ptr = {} } = {}) { + return { + resolveTxt: async (name) => { + if (!(name in txt)) { const e = new Error('ENOTFOUND'); e.code = 'ENOTFOUND'; throw e; } + return txt[name].map((record) => [record]); + }, + resolveMx: async () => { + if (!mx.length) { const e = new Error('ENODATA'); e.code = 'ENODATA'; throw e; } + return mx; + }, + resolve4: async (host) => { + if (!(host in a)) { const e = new Error('ENOTFOUND'); e.code = 'ENOTFOUND'; throw e; } + return a[host]; + }, + reverse: async (ip) => { + if (!(ip in ptr)) { const e = new Error('ENOTFOUND'); e.code = 'ENOTFOUND'; throw e; } + return ptr[ip]; + }, + }; +} + +describe('buildMailDnsRecords', () => { + test('produces SPF, DKIM, DMARC, and MX records', () => { + const records = buildMailDnsRecords({ + domain: 'example.com', + mailIp: '203.0.113.9', + mailHostname: 'mail.example.com', + spfInclude: null, + dkimKey: DKIM, + }); + expect(records).toEqual([ + { type: 'TXT', name: 'example.com', value: 'v=spf1 ip4:203.0.113.9 -all' }, + { type: 'TXT', name: 'os20261008._domainkey.example.com', value: `v=DKIM1; k=rsa; p=${DKIM.publicKey}` }, + { type: 'TXT', name: '_dmarc.example.com', value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com' }, + { type: 'MX', name: 'example.com', value: '10 mail.example.com.' }, + ]); + }); + + test('adds include when a relayhost SPF include is configured', () => { + const [spf] = buildMailDnsRecords({ + domain: 'example.com', mailIp: '203.0.113.9', mailHostname: null, + spfInclude: 'relay.example.net', dkimKey: null, + }); + expect(spf.value).toBe('v=spf1 ip4:203.0.113.9 include:relay.example.net -all'); + }); +}); + +describe('spf matching', () => { + test('ipv4InCidr handles plain addresses and prefixes', () => { + expect(ipv4InCidr('203.0.113.9', '203.0.113.9')).toBe(true); + expect(ipv4InCidr('203.0.113.9', '203.0.113.0/24')).toBe(true); + expect(ipv4InCidr('203.0.114.9', '203.0.113.0/24')).toBe(false); + expect(ipv4InCidr('not-an-ip', '203.0.113.0/24')).toBe(false); + }); + + test('spfRecordPasses on ip4 or the configured include', () => { + expect(spfRecordPasses('v=spf1 ip4:203.0.113.0/24 -all', { mailIp: '203.0.113.9' })).toBe(true); + expect(spfRecordPasses('v=spf1 include:relay.net -all', { mailIp: '203.0.113.9', spfInclude: 'relay.net' })).toBe(true); + expect(spfRecordPasses('v=spf1 a mx -all', { mailIp: '203.0.113.9' })).toBe(false); + }); +}); + +test('dkimRecordPublicKey extracts and de-whitespaces p=', () => { + expect(dkimRecordPublicKey('v=DKIM1; k=rsa; p=AB CD')).toBe('ABCD'); + expect(dkimRecordPublicKey('v=DKIM1; k=rsa')).toBeNull(); +}); + +describe('checkMailDns', () => { + const happyZone = { + txt: { + 'example.com': ['v=spf1 ip4:203.0.113.9 -all'], + 'os20261008._domainkey.example.com': [`v=DKIM1; k=rsa; p=${DKIM.publicKey}`], + '_dmarc.example.com': ['v=DMARC1; p=quarantine'], + }, + mx: [{ exchange: 'mail.example.com', priority: 10 }], + a: { 'mail.example.com': ['203.0.113.9'] }, + ptr: { '203.0.113.9': ['mail.example.com'] }, + }; + const params = { + domain: 'example.com', + mailIp: '203.0.113.9', + mailHostname: 'mail.example.com', + spfInclude: null, + dkimKey: DKIM, + }; + + test('all green when every record is correct', async () => { + const result = await checkMailDns({ ...params, resolver: fakeResolver(happyZone) }); + expect(result.spf.pass).toBe(true); + expect(result.dkim.pass).toBe(true); + expect(result.dmarc.pass).toBe(true); + expect(result.mx.pass).toBe(true); + expect(result.ptr).toEqual({ name: 'mail.example.com', forwardConfirmed: true }); + expect(result.warnings).toEqual([]); + }); + + test('fails each gate independently on an empty zone', async () => { + const result = await checkMailDns({ ...params, resolver: fakeResolver() }); + expect(result.spf.pass).toBe(false); + expect(result.dkim.pass).toBe(false); + expect(result.dmarc.pass).toBe(false); + expect(result.mx.pass).toBe(false); + expect(result.warnings).toContain('No PTR record for 203.0.113.9.'); + }); + + test('wrong DKIM key fails even when a record is published', async () => { + const zone = { + ...happyZone, + txt: { ...happyZone.txt, 'os20261008._domainkey.example.com': ['v=DKIM1; k=rsa; p=SOMEOTHERKEY'] }, + }; + const result = await checkMailDns({ ...params, resolver: fakeResolver(zone) }); + expect(result.dkim.pass).toBe(false); + expect(result.dkim.published).toBe(true); + }); + + test('warns when PTR does not match mail_hostname', async () => { + const zone = { + ...happyZone, + ptr: { '203.0.113.9': ['other.example.net'] }, + a: { ...happyZone.a, 'other.example.net': ['203.0.113.9'] }, + }; + const result = await checkMailDns({ ...params, resolver: fakeResolver(zone) }); + expect(result.warnings).toContain('PTR other.example.net does not match mail_hostname mail.example.com.'); + }); +}); diff --git a/create-a-container/utils/__tests__/unsubscribe-token.test.js b/create-a-container/utils/__tests__/unsubscribe-token.test.js new file mode 100644 index 00000000..6a95b7c7 --- /dev/null +++ b/create-a-container/utils/__tests__/unsubscribe-token.test.js @@ -0,0 +1,50 @@ +/** + * Unit tests for utils/unsubscribe-token.js — roundtrip, tamper resistance, + * key selection, and the all-failures-look-identical contract. + */ + +const crypto = require('crypto'); +const { createUnsubscribeToken, verifyUnsubscribeToken } = require('../unsubscribe-token'); + +const key = { kid: 'a1b2c3d4', secret: crypto.randomBytes(32).toString('base64') }; +const otherKey = { kid: 'ffffffff', secret: crypto.randomBytes(32).toString('base64') }; +const payload = { accountId: '7d9fb37e-0000-4000-8000-000000000001', recipient: 'Someone@Example.COM' }; + +test('roundtrip returns accountId and lowercased recipient', () => { + const token = createUnsubscribeToken(payload, key); + expect(token).not.toMatch(/[^A-Za-z0-9_-]/); // base64url — URL-safe + const verified = verifyUnsubscribeToken(token, [key]); + expect(verified).toMatchObject({ + accountId: payload.accountId, + recipient: 'someone@example.com', + }); + expect(typeof verified.issuedAt).toBe('number'); +}); + +test('any tampering fails verification', () => { + const token = createUnsubscribeToken(payload, key); + const buf = Buffer.from(token, 'base64url'); + for (const index of [2, buf.length - 1, 20]) { + const tampered = Buffer.from(buf); + tampered[index] ^= 0x01; + expect(verifyUnsubscribeToken(tampered.toString('base64url'), [key])).toBeNull(); + } +}); + +test('wrong key and unknown kid both fail', () => { + const token = createUnsubscribeToken(payload, key); + expect(verifyUnsubscribeToken(token, [{ kid: key.kid, secret: otherKey.secret }])).toBeNull(); + expect(verifyUnsubscribeToken(token, [otherKey])).toBeNull(); + expect(verifyUnsubscribeToken(token, [])).toBeNull(); +}); + +test('verifies against the matching key in a multi-key set (rotation)', () => { + const token = createUnsubscribeToken(payload, key); + expect(verifyUnsubscribeToken(token, [otherKey, key])).not.toBeNull(); +}); + +test('garbage input returns null, never throws', () => { + for (const junk of ['', 'x', '!!!!', 'AAAA', 'A'.repeat(500), null, undefined]) { + expect(verifyUnsubscribeToken(junk, [key])).toBeNull(); + } +}); diff --git a/create-a-container/utils/agent-config.js b/create-a-container/utils/agent-config.js index 86a39f39..94668018 100644 --- a/create-a-container/utils/agent-config.js +++ b/create-a-container/utils/agent-config.js @@ -9,8 +9,9 @@ const crypto = require('crypto'); const { Op } = require('sequelize'); -const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, Volume } = require('../models'); +const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, Volume, DkimKey, Setting, MailUnsubscribeKey, MailAccount } = require('../models'); const { isAgentlessNodeType } = require('./volumes'); +const { DEFAULT_QUOTA_MB } = require('./mail-account'); // Owning host UID/GID for volume directories: Proxmox maps an unprivileged CT's // UID/GID 0 to host 100000, so RW volumes must be owned by 100000 to be writable @@ -225,4 +226,83 @@ function computeConfigEtag(config) { return `"${hash}"`; } -module.exports = { buildAgentConfig, computeConfigEtag }; +/** + * The `mail` snapshot section, sent ONLY to the agent holding the mail-host + * claim (issue #67). Carries everything the agent templates into + * Postfix/Dovecot/OpenDKIM config that is NOT read live from SQL: DKIM + * private keys for can-send domains, receiving domains, mail_hostname, + * relayhost, limits, and the DB connection. The mail_dovecot/mail_postfix + * DB passwords are NOT included — they live in /etc/default/container-creator + * on the mail host (bin/setup-mail-db-roles.sh) or socket auth is used. + * + * @param {object} dbConfig - resolved config/config.js entry for this env + * @returns {Promise} + */ +async function buildMailSnapshot(dbConfig) { + const settings = await Setting.getMultiple([ + 'mail_hostname', 'mail_relayhost', 'mail_relayhost_username', 'mail_relayhost_password', + 'mail_db_host', 'mail_default_quota_mb', 'mail_message_size_limit_mb', + ]); + + const sendDomains = await ExternalDomain.findAll({ + where: { mailEnabled: true, mailDnsVerified: true }, + include: [{ model: DkimKey, as: 'dkimKeys', where: { status: 'active' }, required: true }], + order: [['name', 'ASC'], [{ model: DkimKey, as: 'dkimKeys' }, 'createdAt', 'DESC']], + }); + const receiveDomains = await ExternalDomain.findAll({ + where: { mailEnabled: true, mailMxVerified: true }, + attributes: ['name'], + order: [['name', 'ASC']], + }); + // Every account id (enabled or not) — the agent garbage-collects + // /var/vmail dirs that are no longer in this list, after a retention + // window (deleted accounts). + const mailboxIds = (await MailAccount.findAll({ attributes: ['id'], order: [['id', 'ASC']] })) + .map((a) => a.id); + + return { + hostname: settings.mail_hostname || null, + relayhost: settings.mail_relayhost + ? { + host: settings.mail_relayhost, + username: settings.mail_relayhost_username || null, + password: settings.mail_relayhost_password || null, + } + : null, + messageSizeLimitMb: parseInt(settings.mail_message_size_limit_mb, 10) || 25, + defaultQuotaMb: parseInt(settings.mail_default_quota_mb, 10) || DEFAULT_QUOTA_MB, + // DKIM signing material for domains that can send (newest active key). + dkim: sendDomains.map((d) => ({ + domain: d.name, + selector: d.dkimKeys[0].selector, + privateKey: d.dkimKeys[0].privateKey, + })), + // Domains Postfix accepts inbound mail for on :25. + receiveDomains: receiveDomains.map((d) => d.name), + mailboxIds, + db: { + dialect: dbConfig.dialect, + host: settings.mail_db_host || null, + port: dbConfig.port ? Number(dbConfig.port) : null, + database: dbConfig.database || null, + dovecotUser: 'mail_dovecot', + postfixUser: 'mail_postfix', + }, + // opensource-mail-helper mints List-Unsubscribe tokens with the active + // key; retired-but-verifiable keys ride along so old links keep working + // after a rotation. The manager verifies with the same set. The links' + // base URL is NOT sent — the agent derives it from its MANAGER_URL (the + // manager serves /u/ itself and is never behind oauth2-proxy). + unsubscribe: await buildUnsubscribeSection(), + }; +} + +async function buildUnsubscribeSection() { + await MailUnsubscribeKey.ensureActive(); + const keys = await MailUnsubscribeKey.verifiable(); + return { + keys: keys.map((k) => ({ kid: k.kid, secret: k.secret, active: k.status === 'active' })), + }; +} + +module.exports = { buildAgentConfig, buildMailSnapshot, computeConfigEtag }; diff --git a/create-a-container/utils/dkim.js b/create-a-container/utils/dkim.js new file mode 100644 index 00000000..a9c21147 --- /dev/null +++ b/create-a-container/utils/dkim.js @@ -0,0 +1,36 @@ +/** + * DKIM key management (issue #67). RSA-2048 per the plan; rotation is a + * follow-up (DkimKeys.status already distinguishes active/retired). + */ + +const crypto = require('crypto'); + +/** Date-stamped selector, e.g. "os20261008" — unique enough per domain until rotation lands. */ +function defaultSelector(now = new Date()) { + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + const d = String(now.getUTCDate()).padStart(2, '0'); + return `os${y}${m}${d}`; +} + +/** + * Generate an RSA-2048 DKIM key pair. + * @returns {{selector: string, privateKey: string, publicKey: string}} + * privateKey: PEM (PKCS#1, what OpenDKIM reads); publicKey: base64 DER + * (SPKI) — the p= value of the DNS TXT record. + */ +function generateDkimKeyPair(selector = defaultSelector()) { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + return { + selector, + privateKey: privateKey.export({ type: 'pkcs1', format: 'pem' }).toString(), + publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), + }; +} + +/** The TXT record value to publish at ._domainkey.. */ +function dkimDnsRecordValue(publicKey) { + return `v=DKIM1; k=rsa; p=${publicKey}`; +} + +module.exports = { defaultSelector, generateDkimKeyPair, dkimDnsRecordValue }; diff --git a/create-a-container/utils/mail-account.js b/create-a-container/utils/mail-account.js new file mode 100644 index 00000000..c71c7c74 --- /dev/null +++ b/create-a-container/utils/mail-account.js @@ -0,0 +1,41 @@ +/** + * Mail account credential helpers (issue #67). + * + * Passwords are 256-bit random, shown to the user exactly once, and stored + * as Argon2id PHC strings. parallelism MUST stay 1: Dovecot verifies + * {ARGON2ID} hashes via libsodium, which only supports p=1. + */ + +const crypto = require('crypto'); +const argon2 = require('argon2'); + +// Admin-only local parts (RFC 2142 role addresses + operational names). +const RESERVED_LOCAL_PARTS = [ + 'postmaster', 'abuse', 'hostmaster', 'webmaster', 'root', + 'admin', 'security', 'noreply', 'dmarc', +]; + +const DEFAULT_QUOTA_MB = 1024; + +/** @returns {string} 43-char base64url string (32 random bytes). */ +function generateMailPassword() { + return crypto.randomBytes(32).toString('base64url'); +} + +/** @param {string} password @returns {Promise} Argon2id PHC string with p=1. */ +async function hashMailPassword(password) { + return argon2.hash(password, { type: argon2.argon2id, parallelism: 1 }); +} + +/** @param {string} localPart */ +function isReservedLocalPart(localPart) { + return RESERVED_LOCAL_PARTS.includes(String(localPart).toLowerCase()); +} + +module.exports = { + RESERVED_LOCAL_PARTS, + DEFAULT_QUOTA_MB, + generateMailPassword, + hashMailPassword, + isReservedLocalPart, +}; diff --git a/create-a-container/utils/mail-dns.js b/create-a-container/utils/mail-dns.js new file mode 100644 index 00000000..d529376a --- /dev/null +++ b/create-a-container/utils/mail-dns.js @@ -0,0 +1,249 @@ +/** + * Mail DNS records + Check DNS (issue #67). + * + * buildMailDnsRecords: the records an admin must publish for a domain. + * checkMailDns: verifies them against public resolvers. Send gate = + * SPF + DKIM + DMARC; receive gate = MX. PTR problems are warnings only. + * + * SPF evaluation is deliberately record-based per the plan: pass when the + * record's ip4 mechanisms cover the mail IP, or when it contains the + * configured mail_spf_include. (Full RFC 7208 evaluation — a/mx/redirect + * chasing — is a follow-up.) + */ + +const dns = require('dns'); +const { dkimDnsRecordValue } = require('./dkim'); + +const DEFAULT_RESOLVERS = ['1.1.1.1', '8.8.8.8']; + +/** + * @param {object} p + * @param {string} p.domain + * @param {string|null} p.mailIp - externalIp of the mail host's site + * @param {string|null} p.mailHostname - mail_hostname setting (MX target / HELO) + * @param {string|null} p.spfInclude - mail_spf_include setting (required with a relayhost) + * @param {{selector: string, publicKey: string}|null} p.dkimKey + * @returns {Array<{type: string, name: string, value: string}>} + */ +function buildMailDnsRecords({ domain, mailIp, mailHostname, spfInclude, dkimKey }) { + const spfParts = ['v=spf1']; + if (mailIp) spfParts.push(`ip4:${mailIp}`); + if (spfInclude) spfParts.push(`include:${spfInclude}`); + spfParts.push('-all'); + + const records = [ + { type: 'TXT', name: domain, value: spfParts.join(' ') }, + ]; + if (dkimKey) { + records.push({ + type: 'TXT', + name: `${dkimKey.selector}._domainkey.${domain}`, + value: dkimDnsRecordValue(dkimKey.publicKey), + }); + } + records.push({ + type: 'TXT', + name: `_dmarc.${domain}`, + value: `v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain}`, + }); + if (mailHostname) { + records.push({ type: 'MX', name: domain, value: `10 ${mailHostname}.` }); + } + return records; +} + +/** ip4:[/] mechanism match for an IPv4 address. */ +function ipv4InCidr(ip, cidr) { + const [base, prefixStr] = cidr.split('/'); + const prefix = prefixStr === undefined ? 32 : parseInt(prefixStr, 10); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false; + const toInt = (addr) => { + const parts = addr.split('.').map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + }; + const ipInt = toInt(ip); + const baseInt = toInt(base); + if (ipInt === null || baseInt === null) return false; + if (prefix === 0) return true; + const mask = (0xffffffff << (32 - prefix)) >>> 0; + return (ipInt & mask) === (baseInt & mask); +} + +/** + * Record-based SPF pass: any ip4 mechanism covers mailIp, or the record + * contains include:. + */ +function spfRecordPasses(record, { mailIp, spfInclude }) { + const mechanisms = record.trim().split(/\s+/).slice(1); + for (const mech of mechanisms) { + const m = mech.replace(/^\+/, ''); + if (mailIp && m.toLowerCase().startsWith('ip4:') && ipv4InCidr(mailIp, m.slice(4))) return true; + if (spfInclude && m.toLowerCase() === `include:${spfInclude.toLowerCase()}`) return true; + } + return false; +} + +/** Strip quotes/whitespace variations; TXT comes back as chunk arrays. */ +function joinTxt(chunks) { + return chunks.join(''); +} + +/** Normalize a DKIM TXT record's p= value for comparison. */ +function dkimRecordPublicKey(record) { + const match = /(?:^|;)\s*p=([^;]*)/.exec(record); + return match ? match[1].replace(/\s+/g, '') : null; +} + +function makeResolver(resolvers) { + const resolver = new dns.promises.Resolver({ timeout: 5000, tries: 2 }); + resolver.setServers(resolvers && resolvers.length ? resolvers : DEFAULT_RESOLVERS); + return resolver; +} + +async function resolveTxtSafe(resolver, name) { + try { + return (await resolver.resolveTxt(name)).map(joinTxt); + } catch { + return []; + } +} + +/** + * Reverse-lookup an IP and forward-confirm the result. Used by Check DNS + * warnings and by GET /mail/ptr to suggest a mail_hostname. + * @returns {Promise<{name: string|null, forwardConfirmed: boolean}>} + */ +async function lookupPtr({ ip, resolvers, resolver }) { + const r = resolver || makeResolver(resolvers); + try { + const names = await r.reverse(ip); + const name = names[0] || null; + let forwardConfirmed = false; + if (name) { + try { + forwardConfirmed = (await r.resolve4(name)).includes(ip); + } catch { /* not forward-confirmed */ } + } + return { name, forwardConfirmed }; + } catch { + return { name: null, forwardConfirmed: false }; + } +} + +/** Dev-stack bypass: `localhost` (and *.localhost) can never pass a real + * check. Outside production, Check DNS returns an all-pass synthetic result + * so the mail flow is testable end-to-end against MailDev. */ +function isDevBypassDomain(domain) { + return process.env.NODE_ENV !== 'production' && /(^|\.)localhost$/i.test(domain); +} + +function devBypassCheckResult(domain) { + return { + checkedAt: new Date().toISOString(), + devBypass: true, + spf: { pass: true, record: 'dev bypass' }, + dkim: { pass: true, selector: null, published: false }, + dmarc: { pass: true, record: 'dev bypass' }, + mx: { pass: true, hosts: [] }, + ptr: { name: null, forwardConfirmed: false }, + warnings: [`${domain} is a development domain — DNS checks were bypassed (non-production only).`], + }; +} + +/** + * Run the Check DNS pass for a domain. + * + * @param {object} p + * @param {string} p.domain + * @param {string|null} p.mailIp + * @param {string|null} p.mailHostname + * @param {string|null} p.spfInclude + * @param {{selector: string, publicKey: string}|null} p.dkimKey + * @param {string[]} [p.resolvers] - mail_dns_check_resolvers setting + * @param {object} [p.resolver] - injectable for tests + * @returns {Promise} stored as ExternalDomains.mailDnsCheckResult: + * { checkedAt, spf|dkim|dmarc|mx: { pass, ... }, ptr: {...}, warnings: [] } + */ +async function checkMailDns({ domain, mailIp, mailHostname, spfInclude, dkimKey, resolvers, resolver }) { + if (isDevBypassDomain(domain)) return devBypassCheckResult(domain); + const r = resolver || makeResolver(resolvers); + const result = { checkedAt: new Date().toISOString(), warnings: [] }; + + // SPF + const txt = await resolveTxtSafe(r, domain); + const spfRecord = txt.find((t) => /^v=spf1(\s|$)/i.test(t)) || null; + result.spf = { + pass: !!(spfRecord && spfRecordPasses(spfRecord, { mailIp, spfInclude })), + record: spfRecord, + }; + if (!mailIp) result.warnings.push('No mail IP known yet — no agent holds the mail-host claim.'); + + // DKIM + if (dkimKey) { + const dkimTxt = await resolveTxtSafe(r, `${dkimKey.selector}._domainkey.${domain}`); + const published = dkimTxt.map(dkimRecordPublicKey).find((p) => p) || null; + result.dkim = { + pass: !!published && published === dkimKey.publicKey.replace(/\s+/g, ''), + selector: dkimKey.selector, + published: !!published, + }; + } else { + result.dkim = { pass: false, selector: null, published: false }; + result.warnings.push('No active DKIM key for this domain.'); + } + + // DMARC — receivers treat multiple records as a permerror and ignore + // records without a valid p= tag, so both fail the gate. + const dmarcTxt = await resolveTxtSafe(r, `_dmarc.${domain}`); + const dmarcRecords = dmarcTxt.filter((t) => /^v=DMARC1\s*;/i.test(t.trim()) || /^v=DMARC1$/i.test(t.trim())); + const dmarcRecord = dmarcRecords.length === 1 ? dmarcRecords[0] : null; + const dmarcPolicyOk = !!dmarcRecord && /(?:^|;)\s*p\s*=\s*(none|quarantine|reject)\s*(?:;|$)/i.test(dmarcRecord); + result.dmarc = { + pass: dmarcPolicyOk, + record: dmarcRecord ?? dmarcRecords[0] ?? null, + recordCount: dmarcRecords.length, + }; + if (dmarcRecords.length > 1) { + result.warnings.push(`Multiple DMARC records published for _dmarc.${domain} — receivers treat this as an error.`); + } + + // MX → must resolve to the mail IP (receive gate) + let mxPass = false; + let mxHosts = []; + try { + const mx = await r.resolveMx(domain); + mxHosts = mx.sort((a, b) => a.priority - b.priority).map((m) => m.exchange); + for (const host of mxHosts) { + try { + const addrs = await r.resolve4(host); + if (mailIp && addrs.includes(mailIp)) { mxPass = true; break; } + } catch { /* try next exchange */ } + } + } catch { /* no MX */ } + result.mx = { pass: mxPass, hosts: mxHosts }; + + // PTR (warnings only) + if (mailIp) { + const ptr = await lookupPtr({ ip: mailIp, resolver: r }); + result.ptr = ptr; + if (!ptr.name) result.warnings.push(`No PTR record for ${mailIp}.`); + else if (!ptr.forwardConfirmed) result.warnings.push(`PTR ${ptr.name} is not forward-confirmed.`); + else if (mailHostname && ptr.name.toLowerCase() !== mailHostname.toLowerCase()) { + result.warnings.push(`PTR ${ptr.name} does not match mail_hostname ${mailHostname}.`); + } + } + + return result; +} + +module.exports = { + DEFAULT_RESOLVERS, + buildMailDnsRecords, + checkMailDns, + lookupPtr, + // exported for tests + ipv4InCidr, + spfRecordPasses, + dkimRecordPublicKey, +}; diff --git a/create-a-container/utils/mail-host.js b/create-a-container/utils/mail-host.js new file mode 100644 index 00000000..fdf541bc --- /dev/null +++ b/create-a-container/utils/mail-host.js @@ -0,0 +1,95 @@ +/** + * The mail-host claim (issue #67): at most one agent runs the mail stack. + * + * The claim is granted to the first agent that checks in with the `mail` + * service group enabled and everything installed. Postgres/SQLite enforce + * single-holder with a partial unique index; the transaction here serializes + * the race on MySQL/MariaDB too. Mailboxes are local Maildir (Dovecot 2.4 CE + * has no director/replicator), hence one host at a time. + */ + +const { Op } = require('sequelize'); +const { sequelize, Agent, Site, Setting } = require('../models'); + +const MAIL_SERVICE = 'mail'; + +/** The agent currently holding the mail-host claim (with its site), or null. */ +async function getMailHostAgent() { + return Agent.findOne({ + where: { mailHostSince: { [Op.ne]: null } }, + include: [{ model: Site, as: 'site', attributes: ['id', 'name', 'externalIp'] }], + }); +} + +/** + * The IP mail is sent from: externalIp of the mail host's site. The + * mail_self_managed_site_id fallback applies ONLY when no agent holds the + * claim — otherwise DNS checks could verify against an IP unrelated to + * where mail actually runs. + */ +async function getMailIp() { + const holder = await getMailHostAgent(); + if (holder) return holder.site?.externalIp || null; + const selfManagedSiteId = parseInt(await Setting.get('mail_self_managed_site_id'), 10); + if (Number.isInteger(selfManagedSiteId)) { + const site = await Site.findByPk(selfManagedSiteId, { attributes: ['externalIp'] }); + return site?.externalIp || null; + } + return null; +} + +/** + * Process an agent's mail claim at check-in. + * + * @param {object} agent - the checking-in Agent row + * @param {object} p + * @param {boolean} p.wantsMail - agent reports `mail` in enabledServices + * @param {string[]} p.missingBinaries - as reported by the agent + * @returns {Promise<{status: string, missing?: string[]}>} + * status: holder | conflict | unsupported | missing_packages | disabled + */ +async function processMailClaim(agent, { wantsMail, missingBinaries }) { + if (!wantsMail) { + // Turning `mail` off releases the claim; the agent stops/disables the + // units and leaves configs and /var/vmail in place. + if (agent.mailHostSince) await agent.update({ mailHostSince: null }); + return { status: 'disabled' }; + } + if (sequelize.getDialect() === 'sqlite') { + return { status: 'unsupported' }; + } + if (Array.isArray(missingBinaries) && missingBinaries.length > 0) { + // A holder that lost its packages must also lose the claim — otherwise + // every other agent stays in `conflict` while nothing serves mail. + if (agent.mailHostSince) await agent.update({ mailHostSince: null }); + return { status: 'missing_packages', missing: missingBinaries }; + } + return sequelize.transaction(async (transaction) => { + 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' }; + } + if (holder.id === agent.id) return { status: 'holder' }; + return { status: 'conflict' }; + }); +} + +/** Admin release — the next qualifying check-in takes the claim. */ +async function releaseMailHost() { + const holder = await getMailHostAgent(); + if (holder) await holder.update({ mailHostSince: null }); + return holder; +} + +module.exports = { + MAIL_SERVICE, + getMailHostAgent, + getMailIp, + processMailClaim, + releaseMailHost, +}; diff --git a/create-a-container/utils/mail-views.js b/create-a-container/utils/mail-views.js new file mode 100644 index 00000000..ee7df713 --- /dev/null +++ b/create-a-container/utils/mail-views.js @@ -0,0 +1,80 @@ +/** + * Per-dialect SQL for the mail views — the stable contract Dovecot and + * Postfix read live (consumed by the read-only `mail_dovecot` and + * `mail_postfix` DB roles created by migration 20261012000000): + * + * mail_accounts_v address, password ({ARGON2ID}-prefixed PHC hash), + * home, quota_bytes, can_send, can_receive + * — enabled accounts only (Dovecot passdb/userdb) + * mail_senders_v sender, login, account_id, unsubscribe_headers + * for can-send accounts (Postfix + * smtpd_sender_login_maps; opensource-mail-helper uses + * account_id + unsubscribe_headers to mint + * List-Unsubscribe tokens) + * mail_recipients_v recipient — deliverable addresses on can-receive + * domains (Postfix virtual_mailbox_maps: unknown + * recipients rejected at RCPT, no backscatter) + * mail_suppressions_v sender, recipient + * (submission milter — reject at RCPT) + * + * SQLite cannot host the mail stack, but the views are still created there so + * the schema stays identical across dialects for tests. + */ + +const VIEW_NAMES = ['mail_accounts_v', 'mail_senders_v', 'mail_recipients_v', 'mail_suppressions_v']; + +/** + * Build the CREATE VIEW statements for a dialect. + * @param {string} dialect - 'postgres' | 'mysql' | 'mariadb' | 'sqlite' + * @returns {string[]} ordered CREATE VIEW statements + */ +function createViewsSql(dialect) { + const mysql = dialect === 'mysql' || dialect === 'mariadb'; + // Identifier quoting + const q = mysql ? (s) => `\`${s}\`` : (s) => `"${s}"`; + // String concatenation + const cat = mysql + ? (...parts) => `CONCAT(${parts.join(', ')})` + : (...parts) => parts.join(' || '); + + const address = `LOWER(${cat(`ma.${q('localPart')}`, "'@'", `ed.${q('name')}`)})`; + const canSend = `(ed.${q('mailEnabled')} AND ed.${q('mailDnsVerified')})`; + const canReceive = `(ed.${q('mailEnabled')} AND ed.${q('mailMxVerified')})`; + const joins = + `FROM ${q('MailAccounts')} ma ` + + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`; + + return [ + `CREATE VIEW mail_accounts_v AS ` + + `SELECT ${address} AS address, ` + + `${cat("'{ARGON2ID}'", `ma.${q('passwordHash')}`)} AS password, ` + + `${cat("'/var/vmail/'", `ma.${q('id')}`)} AS home, ` + + `ma.${q('quotaBytes')} AS quota_bytes, ` + + `${canSend} AS can_send, ` + + `${canReceive} AS can_receive ` + + `${joins} WHERE ma.${q('enabled')}`, + + `CREATE VIEW mail_senders_v AS ` + + `SELECT ${address} AS sender, ${address} AS login, ` + + `ma.${q('id')} AS account_id, ` + + `ma.${q('unsubscribeHeaders')} AS unsubscribe_headers ` + + `${joins} WHERE ma.${q('enabled')} AND ${canSend}`, + + `CREATE VIEW mail_recipients_v AS ` + + `SELECT ${address} AS recipient ` + + `${joins} WHERE ma.${q('enabled')} AND ${canReceive}`, + + `CREATE VIEW mail_suppressions_v AS ` + + `SELECT ${address} AS sender, ms.${q('recipient')} AS recipient ` + + `FROM ${q('MailSuppressions')} ms ` + + `JOIN ${q('MailAccounts')} ma ON ma.${q('id')} = ms.${q('mailAccountId')} ` + + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`, + ]; +} + +/** DROP statements, reverse order. */ +function dropViewsSql() { + return [...VIEW_NAMES].reverse().map((v) => `DROP VIEW IF EXISTS ${v}`); +} + +module.exports = { VIEW_NAMES, createViewsSql, dropViewsSql }; diff --git a/create-a-container/utils/unsubscribe-token.js b/create-a-container/utils/unsubscribe-token.js new file mode 100644 index 00000000..4398a6da --- /dev/null +++ b/create-a-container/utils/unsubscribe-token.js @@ -0,0 +1,78 @@ +/** + * RFC 8058 one-click unsubscribe tokens (issue #67). + * + * AES-256-GCM over { v, accountId, recipient, iat } with the kid bound as + * AAD. The URL reveals nothing about sender or recipient; tampering fails + * authentication; tokens cannot be generated without the key, so knowing + * the URL format doesn't let anyone unsubscribe other addresses. + * + * Layout (base64url): [version:1][kidLen:1][kid][iv:12][tag:16][ciphertext] + * + * Pure crypto — key rows live in the MailUnsubscribeKeys model. + */ + +const crypto = require('crypto'); + +const VERSION = 1; +const IV_LEN = 12; +const TAG_LEN = 16; + +function secretBuffer(secret) { + return Buffer.isBuffer(secret) ? secret : Buffer.from(secret, 'base64'); +} + +/** + * @param {{accountId: string, recipient: string}} payload + * @param {{kid: string, secret: string|Buffer}} key - the active key + * @returns {string} base64url token + */ +function createUnsubscribeToken({ accountId, recipient }, key) { + const kidBuf = Buffer.from(key.kid, 'utf8'); + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv('aes-256-gcm', secretBuffer(key.secret), iv); + cipher.setAAD(kidBuf); + const payload = Buffer.from(JSON.stringify({ + v: VERSION, + a: accountId, + r: String(recipient).trim().toLowerCase(), + t: Math.floor(Date.now() / 1000), + })); + const ciphertext = Buffer.concat([cipher.update(payload), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([Buffer.from([VERSION, kidBuf.length]), kidBuf, iv, tag, ciphertext]) + .toString('base64url'); +} + +/** + * @param {string} token + * @param {Array<{kid: string, secret: string|Buffer}>} keys - verifiable keys + * @returns {{accountId: string, recipient: string, issuedAt: number}|null} + * null for ANY failure — callers must answer with the same generic 400. + */ +function verifyUnsubscribeToken(token, keys) { + const buf = Buffer.from(String(token), 'base64url'); + if (buf.length < 2 || buf[0] !== VERSION) return null; + const kidLen = buf[1]; + const headerLen = 2 + kidLen + IV_LEN + TAG_LEN; + if (buf.length <= headerLen) return null; + const kid = buf.subarray(2, 2 + kidLen).toString('utf8'); + const key = keys.find((k) => k.kid === kid); + if (!key) return null; + const iv = buf.subarray(2 + kidLen, 2 + kidLen + IV_LEN); + const tag = buf.subarray(2 + kidLen + IV_LEN, headerLen); + const ciphertext = buf.subarray(headerLen); + try { + const decipher = crypto.createDecipheriv('aes-256-gcm', secretBuffer(key.secret), iv); + decipher.setAAD(Buffer.from(kid, 'utf8')); + decipher.setAuthTag(tag); + const payload = JSON.parse( + Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'), + ); + if (payload.v !== VERSION || !payload.a || !payload.r) return null; + return { accountId: payload.a, recipient: payload.r, issuedAt: payload.t }; + } catch { + return null; + } +} + +module.exports = { createUnsubscribeToken, verifyUnsubscribeToken }; diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 353f0164..b017d2c3 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -20,6 +20,10 @@ erDiagram Services ||--|| TransportServices : "type: transport" Services ||--|| DnsServices : "type: dns" ExternalDomains ||--o{ HTTPServices : "used by" + ExternalDomains ||--o{ DkimKeys : signs + ExternalDomains ||--o{ MailAccounts : hosts + MailAccounts ||--o{ MailSuppressions : "unsubscribed by" + Users ||--o{ MailAccounts : owns Jobs ||--o{ JobStatuses : tracks Users }o--o{ Groups : "member of" UserGroups }|--|| Users : joins @@ -60,6 +64,11 @@ erDiagram string hostname "unique per site" string ipv4Address json services "per-service state + lastApply" + json enabledServices "AGENT_SERVICES groups" + json missingBinaries "blocks the mail-host claim" + date mailHostSince "single holder (partial unique index)" + boolean isLocal + uuid apiKeyId FK "pinned on first remote check-in" date lastCheckinAt } @@ -132,6 +141,46 @@ erDiagram string cloudflareApiKey int siteId FK "nullable, default site" string authServer "nullable, oauth2-proxy process address" + boolean mailEnabled "admin switch, default false" + boolean mailDnsVerified "SPF+DKIM+DMARC; with mailEnabled => can send" + boolean mailMxVerified "MX -> mail IP; with mailEnabled => can receive" + date mailDnsCheckedAt + json mailDnsCheckResult "last Check DNS outcome" + } + + DkimKeys { + int id PK + int externalDomainId FK + string selector "unique per domain" + text privateKey "never serialized in API responses" + text publicKey "DNS p= value" + enum status "active | retired" + } + + MailAccounts { + uuid id PK + int uidNumber FK "owner; admin-transferable" + int externalDomainId FK + string localPart "lowercase; unique per domain" + string description + string passwordHash "Argon2id p=1; never serialized" + boolean enabled "default true; gates the SQL views" + bigint quotaBytes + boolean unsubscribeHeaders "default true (RFC 8058)" + date lastRotatedAt + } + + MailSuppressions { + int id PK + uuid mailAccountId FK + string recipient "lowercase; unique per account" + enum source "one-click | admin" + } + + MailUnsubscribeKeys { + string kid PK + string secret "AES-256-GCM key; never serialized" + enum status "active | retired (verifies 1 more year)" } Jobs { @@ -222,7 +271,33 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co Persistent bind-mount attached to a container (issue #421). Unique composite indexes on `(containerId, name)` and `(containerId, mountPath)`. `hostPath` is derived server-side from the node volume storage's actual configured `path` (`/volumes/site-///`; scoped by site so containers with the same hostname across sites don't collide on shared storage, and by owner so retained data is only reattached for the same owner) and is null until the create job derives it. `mode` is `ro`/`rw`; each row renders to a Proxmox `mpN` bind mount (`Volume.buildMountConfig`). `status` (`pending` → `ready` | `failed`) is the readiness barrier the create/reconfigure jobs block on — the site agent creates the host directory and reports the result at check-in, which the manager writes to `status`/`statusMessage`/`appliedAt`. `builtin` marks the retired `quick_and_dirty` mount backfilled onto pre-#421 containers for reference (never re-applied; its live `mp0` slot is reserved by `buildMountConfig`). Belongs to Container. See [Volumes](../admins/core-concepts/volumes.md). ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). Mail gates (`mailEnabled`, `mailDnsVerified`, `mailMxVerified`) are changed only via the `…/mail/*` endpoints; has many DkimKeys and MailAccounts. + +## Mail Models (issue #67) + +### DkimKey +RSA-2048 signing key per external domain, generated at domain creation (selector `osYYYYMMDD`). `status` supports future rotation. The private key is never serialized in API responses — it reaches only the mail-host agent snapshot and the admin DKIM export. + +### MailAccount +A user-owned SMTP/IMAP account on a can-send domain. Password stored as an Argon2id PHC string with `p=1` (Dovecot verifies via libsodium, which only supports parallelism 1); the plaintext is returned exactly once at create/rotate. `enabled` gates the SQL views, so disabling locks the account out instantly. + +### MailSuppression +A recipient who one-click-unsubscribed (RFC 8058) from a MailAccount, or was suppressed by an admin. Enforced synchronously at RCPT on the submission ports. + +### MailUnsubscribeKey +AES-256-GCM key for unsubscribe tokens. One `active` key; `retired` keys keep verifying previously issued links for one year. + +### Mail Views +The dialect-aware views created by migration `20261008000006` (SQL built in `create-a-container/utils/mail-views.js`) are the **stable contract** Dovecot/Postfix — or a self-managed MTA — read live: + +| View | Columns | Consumer | +|------|---------|----------| +| `mail_accounts_v` | `address`, `password` (`{ARGON2ID}`-prefixed), `home`, `quota_bytes`, `can_send`, `can_receive` — enabled accounts only | Dovecot passdb/userdb (`mail_dovecot` role) | +| `mail_senders_v` | `sender`, `login`, `account_id`, `unsubscribe_headers` — can-send accounts | Postfix `smtpd_sender_login_maps` + opensource-mail-helper (`mail_postfix` role) | +| `mail_recipients_v` | `recipient` — deliverable addresses on can-receive domains | Postfix `virtual_mailbox_maps` (unknown recipients rejected at RCPT) | +| `mail_suppressions_v` | `sender`, `recipient` | opensource-mail-helper milter — reject at RCPT (`mail_postfix` role) | + +Migration `20261012000000` creates the two read-only roles and grants; the views migration also grants best-effort so either ordering converges. ## User Management Models From ed5983e3ed0eacc8fcedfe53a948490fc961f03b Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 28 Oct 2026 16:15:03 -0700 Subject: [PATCH 02/10] Add encrypted one-click unsubscribe tokens Add AES-256-GCM token creation and verification for #67, concealing account and recipient data and authenticating the key ID. Allow the VS Code SQLite dependency's install scripts. --- create-a-container/package.json | 3 ++ mail-helper/src/unsubscribe-token.js | 78 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 mail-helper/src/unsubscribe-token.js diff --git a/create-a-container/package.json b/create-a-container/package.json index 3348141d..a2bb2bfc 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -48,5 +48,8 @@ "nodemon": "^3.1.10", "sequelize-cli": "^6.6.3", "supertest": "^7.2.2" + }, + "allowScripts": { + "@vscode/sqlite3@5.1.12-vscode": true } } diff --git a/mail-helper/src/unsubscribe-token.js b/mail-helper/src/unsubscribe-token.js new file mode 100644 index 00000000..4398a6da --- /dev/null +++ b/mail-helper/src/unsubscribe-token.js @@ -0,0 +1,78 @@ +/** + * RFC 8058 one-click unsubscribe tokens (issue #67). + * + * AES-256-GCM over { v, accountId, recipient, iat } with the kid bound as + * AAD. The URL reveals nothing about sender or recipient; tampering fails + * authentication; tokens cannot be generated without the key, so knowing + * the URL format doesn't let anyone unsubscribe other addresses. + * + * Layout (base64url): [version:1][kidLen:1][kid][iv:12][tag:16][ciphertext] + * + * Pure crypto — key rows live in the MailUnsubscribeKeys model. + */ + +const crypto = require('crypto'); + +const VERSION = 1; +const IV_LEN = 12; +const TAG_LEN = 16; + +function secretBuffer(secret) { + return Buffer.isBuffer(secret) ? secret : Buffer.from(secret, 'base64'); +} + +/** + * @param {{accountId: string, recipient: string}} payload + * @param {{kid: string, secret: string|Buffer}} key - the active key + * @returns {string} base64url token + */ +function createUnsubscribeToken({ accountId, recipient }, key) { + const kidBuf = Buffer.from(key.kid, 'utf8'); + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv('aes-256-gcm', secretBuffer(key.secret), iv); + cipher.setAAD(kidBuf); + const payload = Buffer.from(JSON.stringify({ + v: VERSION, + a: accountId, + r: String(recipient).trim().toLowerCase(), + t: Math.floor(Date.now() / 1000), + })); + const ciphertext = Buffer.concat([cipher.update(payload), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([Buffer.from([VERSION, kidBuf.length]), kidBuf, iv, tag, ciphertext]) + .toString('base64url'); +} + +/** + * @param {string} token + * @param {Array<{kid: string, secret: string|Buffer}>} keys - verifiable keys + * @returns {{accountId: string, recipient: string, issuedAt: number}|null} + * null for ANY failure — callers must answer with the same generic 400. + */ +function verifyUnsubscribeToken(token, keys) { + const buf = Buffer.from(String(token), 'base64url'); + if (buf.length < 2 || buf[0] !== VERSION) return null; + const kidLen = buf[1]; + const headerLen = 2 + kidLen + IV_LEN + TAG_LEN; + if (buf.length <= headerLen) return null; + const kid = buf.subarray(2, 2 + kidLen).toString('utf8'); + const key = keys.find((k) => k.kid === kid); + if (!key) return null; + const iv = buf.subarray(2 + kidLen, 2 + kidLen + IV_LEN); + const tag = buf.subarray(2 + kidLen + IV_LEN, headerLen); + const ciphertext = buf.subarray(headerLen); + try { + const decipher = crypto.createDecipheriv('aes-256-gcm', secretBuffer(key.secret), iv); + decipher.setAAD(Buffer.from(kid, 'utf8')); + decipher.setAuthTag(tag); + const payload = JSON.parse( + Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'), + ); + if (payload.v !== VERSION || !payload.a || !payload.r) return null; + return { accountId: payload.a, recipient: payload.r, issuedAt: payload.t }; + } catch { + return null; + } +} + +module.exports = { createUnsubscribeToken, verifyUnsubscribeToken }; From 8753e95183f135f1892c5f5fdcbb4393a951c125 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Thu, 29 Oct 2026 10:18:38 -0700 Subject: [PATCH 03/10] mail: address review feedback from #502 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stateless unsubscribe keys: drop the MailUnsubscribeKeys table/model/ migration; AES keys are now HKDF-derived from the server's session secrets (newest mints, older ones keep old links verifying) - Fold /mail/enable + /mail/disable into PATCH /external-domains/:id with mailEnabled (enable keeps the Check DNS gate) - Fold /api/v1/mail into the agents resource: GET/DELETE /agents/mail-host and GET /agents/mail-host/ptr (per-route csrfGuard — the agents router mounts before the app-level guard) - Move the unsubscribe pages into views/unsubscribe/*.ejs (ejs dependency) - Replace hand-rolled CIDR math with Node's net.BlockList; TODO points at mailauth for full RFC 7208 SPF evaluation - Inline the mail view SQL into migration 20261008000006; delete utils/mail-views.js - Drop the password 'warning' string from create/rotate responses (UI copy) - Comment the isLocal agent field; TODO for folding smtp_url/ smtp_noreply_address into the mail system - Remove stray mail-helper/src/unsubscribe-token.js and the accidental package.json allowScripts entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...1008000005-create-mail-unsubscribe-keys.js | 21 ----- .../20261008000006-create-mail-views.js | 83 +++++++++++++++++-- create-a-container/models/agent.js | 3 + .../models/mail-unsubscribe-key.js | 61 -------------- create-a-container/openapi.v1.yaml | 57 +++++++------ create-a-container/package-lock.json | 13 +++ create-a-container/package.json | 4 +- .../resources/mailaccounts/controller.js | 6 +- create-a-container/routers/api/v1/agents.js | 67 +++++++++++++-- .../routers/api/v1/external-domains.js | 69 ++++++++------- create-a-container/routers/api/v1/index.js | 1 - create-a-container/routers/api/v1/mail.js | 70 ---------------- create-a-container/routers/api/v1/settings.js | 3 + create-a-container/routers/unsubscribe.js | 65 +++++---------- .../utils/__tests__/unsubscribe-token.test.js | 35 +++++++- create-a-container/utils/agent-config.js | 11 +-- create-a-container/utils/mail-dns.js | 22 +++-- create-a-container/utils/mail-views.js | 80 ------------------ create-a-container/utils/unsubscribe-token.js | 42 +++++++++- .../views/unsubscribe/confirm.ejs | 6 ++ create-a-container/views/unsubscribe/done.ejs | 2 + .../views/unsubscribe/invalid.ejs | 2 + create-a-container/views/unsubscribe/page.ejs | 16 ++++ mail-helper/src/unsubscribe-token.js | 78 ----------------- .../docs/developers/database-schema.md | 13 +-- 25 files changed, 369 insertions(+), 461 deletions(-) delete mode 100644 create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js delete mode 100644 create-a-container/models/mail-unsubscribe-key.js delete mode 100644 create-a-container/routers/api/v1/mail.js delete mode 100644 create-a-container/utils/mail-views.js create mode 100644 create-a-container/views/unsubscribe/confirm.ejs create mode 100644 create-a-container/views/unsubscribe/done.ejs create mode 100644 create-a-container/views/unsubscribe/invalid.ejs create mode 100644 create-a-container/views/unsubscribe/page.ejs delete mode 100644 mail-helper/src/unsubscribe-token.js diff --git a/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js b/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js deleted file mode 100644 index a4d14c86..00000000 --- a/create-a-container/migrations/20261008000005-create-mail-unsubscribe-keys.js +++ /dev/null @@ -1,21 +0,0 @@ -'use strict'; - -/** AES-256-GCM keys for unsubscribe tokens (issue #67). */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.createTable('MailUnsubscribeKeys', { - kid: { type: Sequelize.STRING(16), primaryKey: true, allowNull: false }, - secret: { type: Sequelize.STRING(64), allowNull: false }, - status: { type: Sequelize.ENUM('active', 'retired'), allowNull: false, defaultValue: 'active' }, - createdAt: { type: Sequelize.DATE, allowNull: false }, - updatedAt: { type: Sequelize.DATE, allowNull: false }, - }); - }, - - async down(queryInterface) { - await queryInterface.dropTable('MailUnsubscribeKeys'); - if (queryInterface.sequelize.getDialect() === 'postgres') { - await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_MailUnsubscribeKeys_status";'); - } - }, -}; diff --git a/create-a-container/migrations/20261008000006-create-mail-views.js b/create-a-container/migrations/20261008000006-create-mail-views.js index a583ecdb..7d616512 100644 --- a/create-a-container/migrations/20261008000006-create-mail-views.js +++ b/create-a-container/migrations/20261008000006-create-mail-views.js @@ -1,13 +1,86 @@ 'use strict'; -const { createViewsSql, dropViewsSql } = require('../utils/mail-views'); - /** * The mail SQL views — the stable contract Dovecot/Postfix (or a - * self-managed MTA) read live. See utils/mail-views.js for the shape. - * Grants are NOT done here: bin/setup-mail-db-roles.sh creates the - * mail_dovecot / mail_postfix users and grants SELECT on the views. + * self-managed MTA) read live: + * + * mail_accounts_v address, password ({ARGON2ID}-prefixed PHC hash), + * home, quota_bytes, can_send, can_receive + * — enabled accounts only (Dovecot passdb/userdb) + * mail_senders_v sender, login, account_id, unsubscribe_headers + * for can-send accounts (Postfix + * smtpd_sender_login_maps; opensource-mail-helper uses + * account_id + unsubscribe_headers to mint + * List-Unsubscribe tokens) + * mail_recipients_v recipient — deliverable addresses on can-receive + * domains (Postfix virtual_mailbox_maps: unknown + * recipients rejected at RCPT, no backscatter) + * mail_suppressions_v sender, recipient + * (submission milter — reject at RCPT) + * + * SQLite cannot host the mail stack, but the views are still created there so + * the schema stays identical across dialects for tests. + * + * Grants are NOT done here: the mail_dovecot / mail_postfix roles are created + * by migration 20261012000000, which grants SELECT on the views. + */ + +const VIEW_NAMES = ['mail_accounts_v', 'mail_senders_v', 'mail_recipients_v', 'mail_suppressions_v']; + +/** + * Build the CREATE VIEW statements for a dialect. + * @param {string} dialect - 'postgres' | 'mysql' | 'mariadb' | 'sqlite' + * @returns {string[]} ordered CREATE VIEW statements */ +function createViewsSql(dialect) { + const mysql = dialect === 'mysql' || dialect === 'mariadb'; + // Identifier quoting + const q = mysql ? (s) => `\`${s}\`` : (s) => `"${s}"`; + // String concatenation + const cat = mysql + ? (...parts) => `CONCAT(${parts.join(', ')})` + : (...parts) => parts.join(' || '); + + const address = `LOWER(${cat(`ma.${q('localPart')}`, "'@'", `ed.${q('name')}`)})`; + const canSend = `(ed.${q('mailEnabled')} AND ed.${q('mailDnsVerified')})`; + const canReceive = `(ed.${q('mailEnabled')} AND ed.${q('mailMxVerified')})`; + const joins = + `FROM ${q('MailAccounts')} ma ` + + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`; + + return [ + `CREATE VIEW mail_accounts_v AS ` + + `SELECT ${address} AS address, ` + + `${cat("'{ARGON2ID}'", `ma.${q('passwordHash')}`)} AS password, ` + + `${cat("'/var/vmail/'", `ma.${q('id')}`)} AS home, ` + + `ma.${q('quotaBytes')} AS quota_bytes, ` + + `${canSend} AS can_send, ` + + `${canReceive} AS can_receive ` + + `${joins} WHERE ma.${q('enabled')}`, + + `CREATE VIEW mail_senders_v AS ` + + `SELECT ${address} AS sender, ${address} AS login, ` + + `ma.${q('id')} AS account_id, ` + + `ma.${q('unsubscribeHeaders')} AS unsubscribe_headers ` + + `${joins} WHERE ma.${q('enabled')} AND ${canSend}`, + + `CREATE VIEW mail_recipients_v AS ` + + `SELECT ${address} AS recipient ` + + `${joins} WHERE ma.${q('enabled')} AND ${canReceive}`, + + `CREATE VIEW mail_suppressions_v AS ` + + `SELECT ${address} AS sender, ms.${q('recipient')} AS recipient ` + + `FROM ${q('MailSuppressions')} ms ` + + `JOIN ${q('MailAccounts')} ma ON ma.${q('id')} = ms.${q('mailAccountId')} ` + + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`, + ]; +} + +/** DROP statements, reverse order. */ +function dropViewsSql() { + return [...VIEW_NAMES].reverse().map((v) => `DROP VIEW IF EXISTS ${v}`); +} + module.exports = { async up(queryInterface) { const dialect = queryInterface.sequelize.getDialect(); diff --git a/create-a-container/models/agent.js b/create-a-container/models/agent.js index 297402bd..33a27493 100644 --- a/create-a-container/models/agent.js +++ b/create-a-container/models/agent.js @@ -47,6 +47,9 @@ module.exports = (sequelize, DataTypes) => { allowNull: true }, isLocal: { + // True for the agent bundled with the manager (checks in over + // localhost, no API key). Remote agents authenticate with a key and + // get pinned via apiKeyId below. type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false diff --git a/create-a-container/models/mail-unsubscribe-key.js b/create-a-container/models/mail-unsubscribe-key.js deleted file mode 100644 index 3049f30b..00000000 --- a/create-a-container/models/mail-unsubscribe-key.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; -const crypto = require('crypto'); -const { Model } = require('sequelize'); - -// Retired keys keep verifying previously issued unsubscribe links for this long. -const RETIRED_KEY_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; - -module.exports = (sequelize, DataTypes) => { - class MailUnsubscribeKey extends Model { - /** True when this key may still verify tokens (active, or retired within retention). */ - canVerify(now = Date.now()) { - if (this.status === 'active') return true; - return now - new Date(this.updatedAt).getTime() < RETIRED_KEY_RETENTION_MS; - } - - /** The single active key, created on first use. */ - static async ensureActive() { - const existing = await MailUnsubscribeKey.findOne({ - where: { status: 'active' }, - order: [['createdAt', 'DESC']], - }); - if (existing) return existing; - return MailUnsubscribeKey.create({ - kid: crypto.randomBytes(4).toString('hex'), - secret: crypto.randomBytes(32).toString('base64'), - }); - } - - /** Active + recently retired keys, newest first — the set that may verify tokens. */ - static async verifiable() { - const keys = await MailUnsubscribeKey.findAll({ order: [['createdAt', 'DESC']] }); - return keys.filter((k) => k.canVerify()); - } - } - - MailUnsubscribeKey.init({ - kid: { - type: DataTypes.STRING(16), - primaryKey: true, - allowNull: false, - comment: 'Short key id embedded (unencrypted) in unsubscribe tokens', - }, - secret: { - type: DataTypes.STRING(64), - allowNull: false, - comment: 'Base64 of 32 random bytes (AES-256-GCM key). Never serialized; sent only in the mail-host snapshot.', - }, - status: { - type: DataTypes.ENUM('active', 'retired'), - allowNull: false, - defaultValue: 'active', - }, - }, { - sequelize, - modelName: 'MailUnsubscribeKey', - tableName: 'MailUnsubscribeKeys', - timestamps: true, - }); - - return MailUnsubscribeKey; -}; diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 037c44d7..e4a44a48 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -464,7 +464,7 @@ components: name: { type: string } authServer: { type: string, nullable: true } hasCloudflareApiKey: { type: boolean } - mailEnabled: { type: boolean, description: 'Read-only here — toggled via the …/mail/enable|disable endpoints' } + mailEnabled: { type: boolean, description: 'Toggled via PATCH /external-domains/{id}; enabling is gated on Check DNS' } mailDnsVerified: { type: boolean, description: 'SPF+DKIM+DMARC verified. With mailEnabled => domain can send' } mailMxVerified: { type: boolean, description: 'MX resolves to the mail IP. With mailEnabled => domain can receive' } mailDnsCheckedAt: { type: string, format: date-time, nullable: true } @@ -498,7 +498,7 @@ components: netboxToken: { type: string } bannerMessage: { type: string, description: 'Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`.' } usagePsiProbeLimit: { type: string, description: 'Max PSI (pressure stall) probes per usage report, spent on the highest-utilization running containers. Non-negative integer as string; "0" disables PSI, blank uses the default (16).' } - mailHostname: { type: string, description: 'HELO / MX hostname — the forward-confirmed PTR name of the mail IP (see GET /mail/ptr)' } + mailHostname: { type: string, description: 'HELO / MX hostname — the forward-confirmed PTR name of the mail IP (see GET /agents/mail-host/ptr)' } mailRelayhost: { type: string, description: 'Optional smarthost; requires mailSpfInclude' } mailRelayhostUsername: { type: string } mailRelayhostPassword: { type: string } @@ -543,7 +543,6 @@ components: username: { type: string } smtp: { type: object, properties: { ports: { type: array, items: { type: integer } } } } imap: { type: object, properties: { ports: { type: array, items: { type: integer } } } } - warning: { type: string } MailDnsRecord: type: object properties: @@ -1699,6 +1698,28 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } + patch: + operationId: patch_external_domain + tags: [External Domains] + summary: Partial update (admin). Only provided fields change; `mailEnabled: true` runs Check DNS first and requires the send gate (SPF + DKIM + DMARC). + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ExternalDomainInput' + - type: object + properties: + mailEnabled: { type: boolean, description: 'Enable (DNS-gated; response includes the full result under `check`) or disable mail for the domain' } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { description: 'mailEnabled: true with SPF/DKIM/DMARC not verified (code: dns_not_configured); MX failure only warns', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } delete: operationId: delete_external_domain tags: [External Domains] @@ -1743,27 +1764,6 @@ paths: description: Domain with updated gates plus the full check result under `check` '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } - /external-domains/{id}/mail/enable: - parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - post: - operationId: enable_domain_mail - tags: [Mail] - summary: Enable mail for a domain (admin) — runs Check DNS first - responses: - '200': { description: Enabled } - '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } - '422': { description: 'SPF/DKIM/DMARC not verified (code: dns_not_configured); MX failure only warns', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } - /external-domains/{id}/mail/disable: - parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - post: - operationId: disable_domain_mail - tags: [Mail] - summary: Disable mail for a domain (admin) — DKIM keys and config are kept - responses: - '200': { description: Disabled } - '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } /external-domains/{id}/mail/dkim: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] get: @@ -1912,7 +1912,7 @@ paths: '403': { description: 'Not an admin (code: admin_required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { $ref: '#/components/responses/NotFound' } - /mail/host: + /agents/mail-host: get: operationId: get_mail_host tags: [Mail] @@ -1920,17 +1920,16 @@ paths: responses: '200': { description: 'Holder agent (or null), mailIp, mailHostname, selfManagedSiteId' } '403': { $ref: '#/components/responses/Forbidden' } - /mail/host/release: - post: + delete: operationId: release_mail_host tags: [Mail] summary: Release the mail-host claim (admin) — next qualifying check-in takes it responses: '200': { description: '`{ released: boolean }`' } '403': { $ref: '#/components/responses/Forbidden' } - /mail/ptr: + /agents/mail-host/ptr: get: - operationId: get_mail_ptr + operationId: get_mail_host_ptr tags: [Mail] summary: PTR suggestion for the mail_hostname setting (admin) responses: diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 04079bd4..63327878 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -11,6 +11,7 @@ "cookie-parser": "^1.4.7", "csrf-sync": "^4.2.1", "dotenv": "^17.2.3", + "ejs": "^7.0.2", "escape-html": "^1.0.3", "express": "^5.2.1", "express-session": "^1.19.0", @@ -2730,6 +2731,18 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz", + "integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.422", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", diff --git a/create-a-container/package.json b/create-a-container/package.json index a2bb2bfc..64c71538 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -25,6 +25,7 @@ "cookie-parser": "^1.4.7", "csrf-sync": "^4.2.1", "dotenv": "^17.2.3", + "ejs": "^7.0.2", "escape-html": "^1.0.3", "express": "^5.2.1", "express-session": "^1.19.0", @@ -48,8 +49,5 @@ "nodemon": "^3.1.10", "sequelize-cli": "^6.6.3", "supertest": "^7.2.2" - }, - "allowScripts": { - "@vscode/sqlite3@5.1.12-vscode": true } } diff --git a/create-a-container/resources/mailaccounts/controller.js b/create-a-container/resources/mailaccounts/controller.js index 13e6418f..62a5ec6c 100644 --- a/create-a-container/resources/mailaccounts/controller.js +++ b/create-a-container/resources/mailaccounts/controller.js @@ -2,8 +2,6 @@ const svc = require('./service'); const { serializeMailAccount, serializeSuppression } = require('./serializer'); const { asyncHandler, ok, created, noContent } = require('../../middlewares/api'); -const PASSWORD_WARNING = 'Save this password — it will not be shown again.'; - function sessionOf(req) { return { uid: req.session.user, isAdmin: !!req.session.isAdmin }; } @@ -25,11 +23,12 @@ const get = asyncHandler(async (req, res) => { const create = asyncHandler(async (req, res) => { const { account, password, connection } = await svc.createAccount(sessionOf(req), req.validated.body); + // The password is returned exactly once; the UI owns the "save this now" + // messaging. return created(res, { ...serializeMailAccount(account), password, connection, - warning: PASSWORD_WARNING, }); }); @@ -44,7 +43,6 @@ const rotatePassword = asyncHandler(async (req, res) => { ...serializeMailAccount(account), password, connection, - warning: PASSWORD_WARNING, }); }); diff --git a/create-a-container/routers/api/v1/agents.js b/create-a-container/routers/api/v1/agents.js index 6e8ba276..7376b95c 100644 --- a/create-a-container/routers/api/v1/agents.js +++ b/create-a-container/routers/api/v1/agents.js @@ -1,17 +1,21 @@ /** - * /api/v1/agents — site agent check-in and status. + * /api/v1/agents — site agent check-in, status, and the mail-host claim. * * POST / agent check-in: records system info + service states, responds with * the site's config snapshot (or 304 when the agent's If-None-Match * ETag still matches). * GET / admin: current status of all agents. + * GET /mail-host admin: who holds the mail-host claim + the mail IP + * DELETE /mail-host admin: release the claim (next qualifying check-in takes it) + * GET /mail-host/ptr admin: PTR suggestion for the mail_hostname setting */ const express = require('express'); -const { Agent, Site, Container, Volume } = require('../../../models'); -const { apiAuth, apiAdmin, localhostOrAdmin, isLocalhostRequest, asyncHandler, ok, noContent, fail } = require('../../../middlewares/api'); +const { Agent, Site, Container, Volume, Setting } = require('../../../models'); +const { apiAuth, apiAdmin, localhostOrAdmin, isLocalhostRequest, csrfGuard, asyncHandler, ok, noContent, fail } = require('../../../middlewares/api'); const { buildAgentConfig, buildMailSnapshot, computeConfigEtag } = require('../../../utils/agent-config'); -const { MAIL_SERVICE, processMailClaim } = require('../../../utils/mail-host'); +const { MAIL_SERVICE, processMailClaim, getMailHostAgent, getMailIp, releaseMailHost } = require('../../../utils/mail-host'); +const { lookupPtr } = require('../../../utils/mail-dns'); const env = process.env.NODE_ENV || 'development'; const dbConfig = require('../../../config/config.js')[env]; @@ -151,9 +155,62 @@ router.get('/', apiAuth, apiAdmin, asyncHandler(async (req, res) => { }))); })); +// --------------------------------------------------------------------------- +// Mail-host claim (issue #67). The mail host is a property of the agent +// fleet, so it lives under /agents rather than a separate API resource. +// Registered before the '/:id/*' routes so 'mail-host' is never read as an id. +// csrfGuard is applied per-route: this router mounts before the app-level +// guard (check-ins carry no CSRF token), and these are session-driven +// admin mutations. +// --------------------------------------------------------------------------- + +router.get('/mail-host', apiAuth, apiAdmin, asyncHandler(async (_req, res) => { + const holder = await getMailHostAgent(); + const settings = await Setting.getMultiple(['mail_hostname', 'mail_self_managed_site_id']); + return ok(res, { + agent: holder + ? { + id: holder.id, + siteId: holder.siteId, + siteName: holder.site?.name || null, + hostname: holder.hostname, + mailHostSince: holder.mailHostSince, + enabledServices: holder.enabledServices, + missingBinaries: holder.missingBinaries, + } + : null, + mailIp: await getMailIp(), + mailHostname: settings.mail_hostname || null, + selfManagedSiteId: parseInt(settings.mail_self_managed_site_id, 10) || null, + }); +})); + +// Release the claim — the next qualifying check-in takes it. +router.delete('/mail-host', apiAuth, apiAdmin, csrfGuard, asyncHandler(async (_req, res) => { + const released = await releaseMailHost(); + return ok(res, { released: !!released }); +})); + +// PTR suggestion for the mail_hostname setting (reverse DNS of the mail IP). +router.get('/mail-host/ptr', apiAuth, apiAdmin, asyncHandler(async (_req, res) => { + const mailIp = await getMailIp(); + const settings = await Setting.getMultiple(['mail_hostname', 'mail_dns_check_resolvers']); + const resolvers = (settings.mail_dns_check_resolvers || '') + .split(',').map((s) => s.trim()).filter(Boolean); + const ptr = mailIp ? await lookupPtr({ ip: mailIp, resolvers }) : { name: null, forwardConfirmed: false }; + return ok(res, { + mailIp, + ptr: ptr.name, + forwardConfirmed: ptr.forwardConfirmed, + mailHostname: settings.mail_hostname || null, + // Suggest the forward-confirmed PTR name as mail_hostname. + suggestion: ptr.forwardConfirmed ? ptr.name : null, + }); +})); + // Clear a remote agent's API-key pin so it can re-pin on its next check-in // (e.g. after rotating the agent's key). -router.delete('/:id/api-key-pin', apiAuth, apiAdmin, asyncHandler(async (req, res) => { +router.delete('/:id/api-key-pin', apiAuth, apiAdmin, csrfGuard, asyncHandler(async (req, res) => { const agent = await Agent.findByPk(req.params.id); if (!agent) return fail(res, 404, 'not_found', 'Agent not found'); await agent.update({ apiKeyId: null }); diff --git a/create-a-container/routers/api/v1/external-domains.js b/create-a-container/routers/api/v1/external-domains.js index 467fa4f0..c7061b50 100644 --- a/create-a-container/routers/api/v1/external-domains.js +++ b/create-a-container/routers/api/v1/external-domains.js @@ -1,7 +1,8 @@ /** * /api/v1/external-domains — admin-only CRUD + per-domain mail enablement. * The Cloudflare API key is write-only: never returned in any response. - * Mail flags are read-only here — they change only via the /mail endpoints. + * mailEnabled toggles via PATCH /:id; the verification flags are read-only + * (they change only via POST /:id/mail/check-dns). */ const express = require('express'); @@ -27,7 +28,8 @@ function serialize(d) { site: d.site ? { id: d.site.id, name: d.site.name } : null, authServer: d.authServer, hasCloudflareApiKey: !!d.cloudflareApiKey, - // Read-only mail state (issue #67) — mutate via the /mail endpoints. + // Read-only mail verification state (issue #67) — refreshed by + // POST /:id/mail/check-dns; mailEnabled toggles via PATCH /:id. mailEnabled: d.mailEnabled, mailDnsVerified: d.mailDnsVerified, mailMxVerified: d.mailMxVerified, @@ -106,6 +108,42 @@ router.put( }), ); +// Partial update. `mailEnabled` is the mail on/off switch (issue #67): +// enabling runs Check DNS and requires the send gate (SPF + DKIM + DMARC); +// disabling keeps keys and config — accounts stop authenticating immediately +// because the SQL views gate on mailEnabled. +router.patch( + '/:id', + asyncHandler(async (req, res) => { + const d = await loadDomain(req.params.id); + const body = req.body || {}; + + const update = {}; + if (body.name !== undefined) update.name = body.name; + for (const f of ['acmeEmail', 'acmeDirectoryUrl', 'cloudflareApiEmail', 'siteId', 'authServer']) { + if (body[f] !== undefined) update[f] = body[f] || null; + } + if (body.cloudflareApiKey && body.cloudflareApiKey.trim() !== '') { + update.cloudflareApiKey = body.cloudflareApiKey; + } + + let check; + if (body.mailEnabled === true) { + check = await runDnsCheck(d); + if (!d.mailDnsVerified) { + throw new ApiError(422, 'dns_not_configured', + 'SPF, DKIM, or DMARC records are missing or wrong — publish the records from GET …/mail/dns and re-check'); + } + update.mailEnabled = true; + } else if (body.mailEnabled === false) { + update.mailEnabled = false; + } + + await d.update(update); + return ok(res, check ? { ...serialize(d), check } : serialize(d)); + }), +); + router.delete( '/:id', asyncHandler(async (req, res) => { @@ -195,33 +233,6 @@ router.post( }), ); -router.post( - '/:id/mail/enable', - asyncHandler(async (req, res) => { - const d = await loadDomain(req.params.id); - const result = await runDnsCheck(d); - // SPF/DKIM/DMARC are hard requirements to send; MX failure only means - // the domain can't receive yet, so it just warns. - if (!d.mailDnsVerified) { - throw new ApiError(422, 'dns_not_configured', - 'SPF, DKIM, or DMARC records are missing or wrong — publish the records from GET …/mail/dns and re-check'); - } - await d.update({ mailEnabled: true }); - return ok(res, { ...serialize(d), check: result }); - }), -); - -router.post( - '/:id/mail/disable', - asyncHandler(async (req, res) => { - const d = await loadDomain(req.params.id); - // Keys and config are kept; accounts stop authenticating immediately - // because the SQL views gate on mailEnabled. - await d.update({ mailEnabled: false }); - return ok(res, serialize(d)); - }), -); - // Self-managed MTA export: the active DKIM private key. Deliberately noisy // in the logs — private-key reads should be rare, intentional events. router.get( diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index c27f2308..93bd01d0 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -92,7 +92,6 @@ router.use('/groups', require('./groups')); router.use('/users', require('./users')); router.use('/apikeys', require('../../../resources/apikeys/router')); router.use('/mail-accounts', require('../../../resources/mailaccounts/router')); -router.use('/mail', require('./mail')); router.use('/settings', require('./settings')); router.use('/jobs', require('./jobs')); router.use('/resource-requests', require('./resource-requests')); diff --git a/create-a-container/routers/api/v1/mail.js b/create-a-container/routers/api/v1/mail.js deleted file mode 100644 index 5185e45b..00000000 --- a/create-a-container/routers/api/v1/mail.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * /api/v1/mail — admin mail-host management (issue #67). - * - * GET /host who holds the mail-host claim + the mail IP - * POST /host/release release the claim (next qualifying check-in takes it) - * GET /ptr PTR suggestion for the mail_hostname setting - */ - -const express = require('express'); -const { Setting } = require('../../../models'); -const { apiAuth, apiAdmin, asyncHandler, ok } = require('../../../middlewares/api'); -const { getMailHostAgent, getMailIp, releaseMailHost } = require('../../../utils/mail-host'); -const { lookupPtr } = require('../../../utils/mail-dns'); - -const router = express.Router(); - -router.use(apiAuth, apiAdmin); - -router.get( - '/host', - asyncHandler(async (_req, res) => { - const holder = await getMailHostAgent(); - const settings = await Setting.getMultiple(['mail_hostname', 'mail_self_managed_site_id']); - return ok(res, { - agent: holder - ? { - id: holder.id, - siteId: holder.siteId, - siteName: holder.site?.name || null, - hostname: holder.hostname, - mailHostSince: holder.mailHostSince, - enabledServices: holder.enabledServices, - missingBinaries: holder.missingBinaries, - } - : null, - mailIp: await getMailIp(), - mailHostname: settings.mail_hostname || null, - selfManagedSiteId: parseInt(settings.mail_self_managed_site_id, 10) || null, - }); - }), -); - -router.post( - '/host/release', - asyncHandler(async (_req, res) => { - const released = await releaseMailHost(); - return ok(res, { released: !!released }); - }), -); - -router.get( - '/ptr', - asyncHandler(async (_req, res) => { - const mailIp = await getMailIp(); - const settings = await Setting.getMultiple(['mail_hostname', 'mail_dns_check_resolvers']); - const resolvers = (settings.mail_dns_check_resolvers || '') - .split(',').map((s) => s.trim()).filter(Boolean); - const ptr = mailIp ? await lookupPtr({ ip: mailIp, resolvers }) : { name: null, forwardConfirmed: false }; - return ok(res, { - mailIp, - ptr: ptr.name, - forwardConfirmed: ptr.forwardConfirmed, - mailHostname: settings.mail_hostname || null, - // Suggest the forward-confirmed PTR name as mail_hostname. - suggestion: ptr.forwardConfirmed ? ptr.name : null, - }); - }), -); - -module.exports = router; diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index 28cd8854..e6a3f886 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -11,6 +11,9 @@ const router = express.Router(); router.use(apiAuth, apiAdmin); const KEYS = [ + // TODO(#67): fold smtp_url/smtp_noreply_address into the mail system — + // the manager's own outbound mail should go through the hosted mail stack + // once it can satisfy that role. 'smtp_url', 'smtp_noreply_address', 'default_container_env_vars', diff --git a/create-a-container/routers/unsubscribe.js b/create-a-container/routers/unsubscribe.js index beeaea31..bde35553 100644 --- a/create-a-container/routers/unsubscribe.js +++ b/create-a-container/routers/unsubscribe.js @@ -12,50 +12,38 @@ * Every failure mode (bad token, unknown kid, deleted account) returns the * same generic 400 so the endpoint can't be used to probe for accounts or * addresses. + * + * Pages are server-rendered from views/unsubscribe/*.ejs — recipients and + * mail providers hit these URLs with no session, so the SPA plays no part. */ +const path = require('path'); const express = require('express'); -const { MailAccount, MailSuppression, MailUnsubscribeKey } = require('../models'); +const ejs = require('ejs'); +const { MailAccount, MailSuppression } = require('../models'); const { asyncHandler } = require('../middlewares/api'); -const { verifyUnsubscribeToken } = require('../utils/unsubscribe-token'); +const { verifyUnsubscribeToken, currentUnsubscribeKeys } = require('../utils/unsubscribe-token'); + +const VIEWS = path.join(__dirname, '..', 'views', 'unsubscribe'); const router = express.Router(); router.use(express.urlencoded({ extended: false })); -function escapeHtml(s) { - return String(s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); +/** Render views/unsubscribe/.ejs inside the shared page chrome. */ +async function renderPage(res, status, view, data = {}) { + const body = await ejs.renderFile(path.join(VIEWS, `${view}.ejs`), data); + const html = await ejs.renderFile(path.join(VIEWS, 'page.ejs'), { title: data.title, body }); + return res.status(status).type('html').send(html); } -function page(title, body) { - return ` - - - - - -${escapeHtml(title)} - - - -
${body}
- -`; +function invalidLink(res) { + return renderPage(res, 400, 'invalid', { title: 'Invalid link' }); } -const GENERIC_400 = page( - 'Invalid link', - '

Invalid link

This unsubscribe link is invalid or has expired.

', -); - /** Verify the token and load its account; null means "answer generic 400". */ async function resolveToken(token) { - const keys = await MailUnsubscribeKey.verifiable(); + const keys = await currentUnsubscribeKeys(); const payload = verifyUnsubscribeToken(token, keys); if (!payload) return null; const account = await MailAccount.findByPk(payload.accountId); @@ -65,31 +53,22 @@ async function resolveToken(token) { router.get('/:token', asyncHandler(async (req, res) => { const resolved = await resolveToken(req.params.token); - if (!resolved) return res.status(400).type('html').send(GENERIC_400); - const recipient = escapeHtml(resolved.recipient); - return res.type('html').send(page('Unsubscribe', ` -

Unsubscribe

-

Stop receiving email to ${recipient} from this sender?

-
- - -
`)); + if (!resolved) return invalidLink(res); + return renderPage(res, 200, 'confirm', { title: 'Unsubscribe', recipient: resolved.recipient }); })); router.post('/:token', asyncHandler(async (req, res) => { // RFC 8058 §3.2: the POST body must be List-Unsubscribe=One-Click. if ((req.body || {})['List-Unsubscribe'] !== 'One-Click') { - return res.status(400).type('html').send(GENERIC_400); + return invalidLink(res); } const resolved = await resolveToken(req.params.token); - if (!resolved) return res.status(400).type('html').send(GENERIC_400); + if (!resolved) return invalidLink(res); await MailSuppression.findOrCreate({ where: { mailAccountId: resolved.account.id, recipient: resolved.recipient }, defaults: { source: 'one-click' }, }); - return res.type('html').send(page('Unsubscribed', ` -

Unsubscribed

-

${escapeHtml(resolved.recipient)} will no longer receive email from this sender.

`)); + return renderPage(res, 200, 'done', { title: 'Unsubscribed', recipient: resolved.recipient }); })); module.exports = router; diff --git a/create-a-container/utils/__tests__/unsubscribe-token.test.js b/create-a-container/utils/__tests__/unsubscribe-token.test.js index 6a95b7c7..e0e60aca 100644 --- a/create-a-container/utils/__tests__/unsubscribe-token.test.js +++ b/create-a-container/utils/__tests__/unsubscribe-token.test.js @@ -1,10 +1,15 @@ /** * Unit tests for utils/unsubscribe-token.js — roundtrip, tamper resistance, - * key selection, and the all-failures-look-identical contract. + * key selection, session-secret derivation, and the all-failures-look-identical + * contract. */ const crypto = require('crypto'); -const { createUnsubscribeToken, verifyUnsubscribeToken } = require('../unsubscribe-token'); +const { + createUnsubscribeToken, + verifyUnsubscribeToken, + deriveUnsubscribeKeys, +} = require('../unsubscribe-token'); const key = { kid: 'a1b2c3d4', secret: crypto.randomBytes(32).toString('base64') }; const otherKey = { kid: 'ffffffff', secret: crypto.randomBytes(32).toString('base64') }; @@ -48,3 +53,29 @@ test('garbage input returns null, never throws', () => { expect(verifyUnsubscribeToken(junk, [key])).toBeNull(); } }); + +describe('deriveUnsubscribeKeys', () => { + const secrets = ['newest-session-secret', 'older-session-secret']; + + test('is deterministic and marks the newest secret active', () => { + const a = deriveUnsubscribeKeys(secrets); + const b = deriveUnsubscribeKeys(secrets); + expect(a).toHaveLength(2); + expect(a[0].active).toBe(true); + expect(a[1].active).toBe(false); + expect(a.map((k) => k.kid)).toEqual(b.map((k) => k.kid)); + expect(a[0].secret.equals(b[0].secret)).toBe(true); + expect(a[0].kid).not.toBe(a[1].kid); + expect(a[0].secret.equals(a[1].secret)).toBe(false); + }); + + test('derived keys roundtrip tokens, including after a rotation', () => { + const [active] = deriveUnsubscribeKeys(secrets); + const token = createUnsubscribeToken(payload, active); + // Rotation: a new secret is prepended; the old derivation still verifies. + const rotated = deriveUnsubscribeKeys(['brand-new-secret', ...secrets]); + expect(verifyUnsubscribeToken(token, rotated)).toMatchObject({ + accountId: payload.accountId, + }); + }); +}); diff --git a/create-a-container/utils/agent-config.js b/create-a-container/utils/agent-config.js index 94668018..0311f462 100644 --- a/create-a-container/utils/agent-config.js +++ b/create-a-container/utils/agent-config.js @@ -9,9 +9,10 @@ const crypto = require('crypto'); const { Op } = require('sequelize'); -const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, Volume, DkimKey, Setting, MailUnsubscribeKey, MailAccount } = require('../models'); +const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain, Volume, DkimKey, Setting, MailAccount } = require('../models'); const { isAgentlessNodeType } = require('./volumes'); const { DEFAULT_QUOTA_MB } = require('./mail-account'); +const { currentUnsubscribeKeys } = require('./unsubscribe-token'); // Owning host UID/GID for volume directories: Proxmox maps an unprivileged CT's // UID/GID 0 to host 100000, so RW volumes must be owned by 100000 to be writable @@ -289,7 +290,8 @@ async function buildMailSnapshot(dbConfig) { postfixUser: 'mail_postfix', }, // opensource-mail-helper mints List-Unsubscribe tokens with the active - // key; retired-but-verifiable keys ride along so old links keep working + // key. The keys are stateless HKDF derivations of the server's session + // secrets: the newest secret mints, older ones keep old links verifying // after a rotation. The manager verifies with the same set. The links' // base URL is NOT sent — the agent derives it from its MANAGER_URL (the // manager serves /u/ itself and is never behind oauth2-proxy). @@ -298,10 +300,9 @@ async function buildMailSnapshot(dbConfig) { } async function buildUnsubscribeSection() { - await MailUnsubscribeKey.ensureActive(); - const keys = await MailUnsubscribeKey.verifiable(); + const keys = await currentUnsubscribeKeys(); return { - keys: keys.map((k) => ({ kid: k.kid, secret: k.secret, active: k.status === 'active' })), + keys: keys.map((k) => ({ kid: k.kid, secret: k.secret.toString('base64'), active: k.active })), }; } diff --git a/create-a-container/utils/mail-dns.js b/create-a-container/utils/mail-dns.js index d529376a..ac80b692 100644 --- a/create-a-container/utils/mail-dns.js +++ b/create-a-container/utils/mail-dns.js @@ -12,6 +12,7 @@ */ const dns = require('dns'); +const net = require('net'); const { dkimDnsRecordValue } = require('./dkim'); const DEFAULT_RESOLVERS = ['1.1.1.1', '8.8.8.8']; @@ -56,23 +57,20 @@ function buildMailDnsRecords({ domain, mailIp, mailHostname, spfInclude, dkimKey function ipv4InCidr(ip, cidr) { const [base, prefixStr] = cidr.split('/'); const prefix = prefixStr === undefined ? 32 : parseInt(prefixStr, 10); + if (!net.isIPv4(ip) || !net.isIPv4(base)) return false; if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false; - const toInt = (addr) => { - const parts = addr.split('.').map(Number); - if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null; - return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; - }; - const ipInt = toInt(ip); - const baseInt = toInt(base); - if (ipInt === null || baseInt === null) return false; - if (prefix === 0) return true; - const mask = (0xffffffff << (32 - prefix)) >>> 0; - return (ipInt & mask) === (baseInt & mask); + const list = new net.BlockList(); + list.addSubnet(base, prefix, 'ipv4'); + return list.check(ip, 'ipv4'); } /** * Record-based SPF pass: any ip4 mechanism covers mailIp, or the record * contains include:. + * + * TODO(#67): full RFC 7208 evaluation (a/mx/redirect/include chasing) — + * `mailauth` is the candidate library; it brings live-evaluation semantics + * and its own resolver plumbing, so adopting it is a follow-up. */ function spfRecordPasses(record, { mailIp, spfInclude }) { const mechanisms = record.trim().split(/\s+/).slice(1); @@ -111,7 +109,7 @@ async function resolveTxtSafe(resolver, name) { /** * Reverse-lookup an IP and forward-confirm the result. Used by Check DNS - * warnings and by GET /mail/ptr to suggest a mail_hostname. + * warnings and by GET /agents/mail-host/ptr to suggest a mail_hostname. * @returns {Promise<{name: string|null, forwardConfirmed: boolean}>} */ async function lookupPtr({ ip, resolvers, resolver }) { diff --git a/create-a-container/utils/mail-views.js b/create-a-container/utils/mail-views.js deleted file mode 100644 index ee7df713..00000000 --- a/create-a-container/utils/mail-views.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Per-dialect SQL for the mail views — the stable contract Dovecot and - * Postfix read live (consumed by the read-only `mail_dovecot` and - * `mail_postfix` DB roles created by migration 20261012000000): - * - * mail_accounts_v address, password ({ARGON2ID}-prefixed PHC hash), - * home, quota_bytes, can_send, can_receive - * — enabled accounts only (Dovecot passdb/userdb) - * mail_senders_v sender, login, account_id, unsubscribe_headers - * for can-send accounts (Postfix - * smtpd_sender_login_maps; opensource-mail-helper uses - * account_id + unsubscribe_headers to mint - * List-Unsubscribe tokens) - * mail_recipients_v recipient — deliverable addresses on can-receive - * domains (Postfix virtual_mailbox_maps: unknown - * recipients rejected at RCPT, no backscatter) - * mail_suppressions_v sender, recipient - * (submission milter — reject at RCPT) - * - * SQLite cannot host the mail stack, but the views are still created there so - * the schema stays identical across dialects for tests. - */ - -const VIEW_NAMES = ['mail_accounts_v', 'mail_senders_v', 'mail_recipients_v', 'mail_suppressions_v']; - -/** - * Build the CREATE VIEW statements for a dialect. - * @param {string} dialect - 'postgres' | 'mysql' | 'mariadb' | 'sqlite' - * @returns {string[]} ordered CREATE VIEW statements - */ -function createViewsSql(dialect) { - const mysql = dialect === 'mysql' || dialect === 'mariadb'; - // Identifier quoting - const q = mysql ? (s) => `\`${s}\`` : (s) => `"${s}"`; - // String concatenation - const cat = mysql - ? (...parts) => `CONCAT(${parts.join(', ')})` - : (...parts) => parts.join(' || '); - - const address = `LOWER(${cat(`ma.${q('localPart')}`, "'@'", `ed.${q('name')}`)})`; - const canSend = `(ed.${q('mailEnabled')} AND ed.${q('mailDnsVerified')})`; - const canReceive = `(ed.${q('mailEnabled')} AND ed.${q('mailMxVerified')})`; - const joins = - `FROM ${q('MailAccounts')} ma ` + - `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`; - - return [ - `CREATE VIEW mail_accounts_v AS ` + - `SELECT ${address} AS address, ` + - `${cat("'{ARGON2ID}'", `ma.${q('passwordHash')}`)} AS password, ` + - `${cat("'/var/vmail/'", `ma.${q('id')}`)} AS home, ` + - `ma.${q('quotaBytes')} AS quota_bytes, ` + - `${canSend} AS can_send, ` + - `${canReceive} AS can_receive ` + - `${joins} WHERE ma.${q('enabled')}`, - - `CREATE VIEW mail_senders_v AS ` + - `SELECT ${address} AS sender, ${address} AS login, ` + - `ma.${q('id')} AS account_id, ` + - `ma.${q('unsubscribeHeaders')} AS unsubscribe_headers ` + - `${joins} WHERE ma.${q('enabled')} AND ${canSend}`, - - `CREATE VIEW mail_recipients_v AS ` + - `SELECT ${address} AS recipient ` + - `${joins} WHERE ma.${q('enabled')} AND ${canReceive}`, - - `CREATE VIEW mail_suppressions_v AS ` + - `SELECT ${address} AS sender, ms.${q('recipient')} AS recipient ` + - `FROM ${q('MailSuppressions')} ms ` + - `JOIN ${q('MailAccounts')} ma ON ma.${q('id')} = ms.${q('mailAccountId')} ` + - `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`, - ]; -} - -/** DROP statements, reverse order. */ -function dropViewsSql() { - return [...VIEW_NAMES].reverse().map((v) => `DROP VIEW IF EXISTS ${v}`); -} - -module.exports = { VIEW_NAMES, createViewsSql, dropViewsSql }; diff --git a/create-a-container/utils/unsubscribe-token.js b/create-a-container/utils/unsubscribe-token.js index 4398a6da..12a0b062 100644 --- a/create-a-container/utils/unsubscribe-token.js +++ b/create-a-container/utils/unsubscribe-token.js @@ -8,7 +8,9 @@ * * Layout (base64url): [version:1][kidLen:1][kid][iv:12][tag:16][ciphertext] * - * Pure crypto — key rows live in the MailUnsubscribeKeys model. + * Keys are STATELESS: each one is HKDF-derived from a server session secret + * (no key table). The newest secret's derivation mints tokens; older + * secrets' derivations keep links verifying after a rotation. */ const crypto = require('crypto'); @@ -16,6 +18,37 @@ const crypto = require('crypto'); const VERSION = 1; const IV_LEN = 12; const TAG_LEN = 16; +const HKDF_INFO = 'opensource-mail unsubscribe v1'; + +/** + * Derive the unsubscribe AES keys from the server's session secrets. + * Deterministic — the same secrets always derive the same keys, so the + * manager and the mail host (via the check-in snapshot) agree without + * shared state. + * @param {string[]} sessionSecrets - newest first (server.js order) + * @returns {Array<{kid: string, secret: Buffer, active: boolean}>} + */ +function deriveUnsubscribeKeys(sessionSecrets) { + return sessionSecrets.map((s, i) => { + const secret = Buffer.from( + crypto.hkdfSync('sha256', Buffer.from(String(s)), Buffer.alloc(0), HKDF_INFO, 32), + ); + // kid identifies the key in the token without revealing it. + const kid = crypto.createHash('sha256').update(secret).digest('hex').slice(0, 8); + return { kid, secret, active: i === 0 }; + }); +} + +/** Session secrets loaded + derived — the key set both /u and the agent snapshot use. */ +async function currentUnsubscribeKeys() { + // Lazy require keeps this module import-safe as pure crypto for tests. + const { SessionSecret } = require('../models'); + const secrets = await SessionSecret.findAll({ + order: [['createdAt', 'DESC']], + attributes: ['secret'], + }); + return deriveUnsubscribeKeys(secrets.map((s) => s.secret)); +} function secretBuffer(secret) { return Buffer.isBuffer(secret) ? secret : Buffer.from(secret, 'base64'); @@ -75,4 +108,9 @@ function verifyUnsubscribeToken(token, keys) { } } -module.exports = { createUnsubscribeToken, verifyUnsubscribeToken }; +module.exports = { + createUnsubscribeToken, + verifyUnsubscribeToken, + deriveUnsubscribeKeys, + currentUnsubscribeKeys, +}; diff --git a/create-a-container/views/unsubscribe/confirm.ejs b/create-a-container/views/unsubscribe/confirm.ejs new file mode 100644 index 00000000..23d41bd3 --- /dev/null +++ b/create-a-container/views/unsubscribe/confirm.ejs @@ -0,0 +1,6 @@ +

Unsubscribe

+

Stop receiving email to <%= recipient %> from this sender?

+
+ + +
diff --git a/create-a-container/views/unsubscribe/done.ejs b/create-a-container/views/unsubscribe/done.ejs new file mode 100644 index 00000000..e136a776 --- /dev/null +++ b/create-a-container/views/unsubscribe/done.ejs @@ -0,0 +1,2 @@ +

Unsubscribed

+

<%= recipient %> will no longer receive email from this sender.

diff --git a/create-a-container/views/unsubscribe/invalid.ejs b/create-a-container/views/unsubscribe/invalid.ejs new file mode 100644 index 00000000..82fc35ac --- /dev/null +++ b/create-a-container/views/unsubscribe/invalid.ejs @@ -0,0 +1,2 @@ +

Invalid link

+

This unsubscribe link is invalid or has expired.

diff --git a/create-a-container/views/unsubscribe/page.ejs b/create-a-container/views/unsubscribe/page.ejs new file mode 100644 index 00000000..39927f93 --- /dev/null +++ b/create-a-container/views/unsubscribe/page.ejs @@ -0,0 +1,16 @@ + + + + + + +<%= title %> + + + +
<%- body %>
+ + diff --git a/mail-helper/src/unsubscribe-token.js b/mail-helper/src/unsubscribe-token.js deleted file mode 100644 index 4398a6da..00000000 --- a/mail-helper/src/unsubscribe-token.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * RFC 8058 one-click unsubscribe tokens (issue #67). - * - * AES-256-GCM over { v, accountId, recipient, iat } with the kid bound as - * AAD. The URL reveals nothing about sender or recipient; tampering fails - * authentication; tokens cannot be generated without the key, so knowing - * the URL format doesn't let anyone unsubscribe other addresses. - * - * Layout (base64url): [version:1][kidLen:1][kid][iv:12][tag:16][ciphertext] - * - * Pure crypto — key rows live in the MailUnsubscribeKeys model. - */ - -const crypto = require('crypto'); - -const VERSION = 1; -const IV_LEN = 12; -const TAG_LEN = 16; - -function secretBuffer(secret) { - return Buffer.isBuffer(secret) ? secret : Buffer.from(secret, 'base64'); -} - -/** - * @param {{accountId: string, recipient: string}} payload - * @param {{kid: string, secret: string|Buffer}} key - the active key - * @returns {string} base64url token - */ -function createUnsubscribeToken({ accountId, recipient }, key) { - const kidBuf = Buffer.from(key.kid, 'utf8'); - const iv = crypto.randomBytes(IV_LEN); - const cipher = crypto.createCipheriv('aes-256-gcm', secretBuffer(key.secret), iv); - cipher.setAAD(kidBuf); - const payload = Buffer.from(JSON.stringify({ - v: VERSION, - a: accountId, - r: String(recipient).trim().toLowerCase(), - t: Math.floor(Date.now() / 1000), - })); - const ciphertext = Buffer.concat([cipher.update(payload), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([Buffer.from([VERSION, kidBuf.length]), kidBuf, iv, tag, ciphertext]) - .toString('base64url'); -} - -/** - * @param {string} token - * @param {Array<{kid: string, secret: string|Buffer}>} keys - verifiable keys - * @returns {{accountId: string, recipient: string, issuedAt: number}|null} - * null for ANY failure — callers must answer with the same generic 400. - */ -function verifyUnsubscribeToken(token, keys) { - const buf = Buffer.from(String(token), 'base64url'); - if (buf.length < 2 || buf[0] !== VERSION) return null; - const kidLen = buf[1]; - const headerLen = 2 + kidLen + IV_LEN + TAG_LEN; - if (buf.length <= headerLen) return null; - const kid = buf.subarray(2, 2 + kidLen).toString('utf8'); - const key = keys.find((k) => k.kid === kid); - if (!key) return null; - const iv = buf.subarray(2 + kidLen, 2 + kidLen + IV_LEN); - const tag = buf.subarray(2 + kidLen + IV_LEN, headerLen); - const ciphertext = buf.subarray(headerLen); - try { - const decipher = crypto.createDecipheriv('aes-256-gcm', secretBuffer(key.secret), iv); - decipher.setAAD(Buffer.from(kid, 'utf8')); - decipher.setAuthTag(tag); - const payload = JSON.parse( - Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'), - ); - if (payload.v !== VERSION || !payload.a || !payload.r) return null; - return { accountId: payload.a, recipient: payload.r, issuedAt: payload.t }; - } catch { - return null; - } -} - -module.exports = { createUnsubscribeToken, verifyUnsubscribeToken }; diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index b017d2c3..62c35044 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -177,12 +177,6 @@ erDiagram enum source "one-click | admin" } - MailUnsubscribeKeys { - string kid PK - string secret "AES-256-GCM key; never serialized" - enum status "active | retired (verifies 1 more year)" - } - Jobs { int id PK string name @@ -282,13 +276,10 @@ RSA-2048 signing key per external domain, generated at domain creation (selector A user-owned SMTP/IMAP account on a can-send domain. Password stored as an Argon2id PHC string with `p=1` (Dovecot verifies via libsodium, which only supports parallelism 1); the plaintext is returned exactly once at create/rotate. `enabled` gates the SQL views, so disabling locks the account out instantly. ### MailSuppression -A recipient who one-click-unsubscribed (RFC 8058) from a MailAccount, or was suppressed by an admin. Enforced synchronously at RCPT on the submission ports. - -### MailUnsubscribeKey -AES-256-GCM key for unsubscribe tokens. One `active` key; `retired` keys keep verifying previously issued links for one year. +A recipient who one-click-unsubscribed (RFC 8058) from a MailAccount, or was suppressed by an admin. Enforced synchronously at RCPT on the submission ports. Unsubscribe tokens are stateless — AES-256-GCM keys are HKDF-derived from the `SessionSecrets` rows (newest mints; older ones keep old links verifying), so there is no key table. ### Mail Views -The dialect-aware views created by migration `20261008000006` (SQL built in `create-a-container/utils/mail-views.js`) are the **stable contract** Dovecot/Postfix — or a self-managed MTA — read live: +The dialect-aware views created by migration `20261008000006` (which contains the per-dialect SQL) are the **stable contract** Dovecot/Postfix — or a self-managed MTA — read live: | View | Columns | Consumer | |------|---------|----------| From e395fe2f41aadb9a22640629ddac43e866e27f64 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 30 Oct 2026 07:39:01 -0700 Subject: [PATCH 04/10] Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- create-a-container/routers/api/v1/external-domains.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create-a-container/routers/api/v1/external-domains.js b/create-a-container/routers/api/v1/external-domains.js index c7061b50..b9d50889 100644 --- a/create-a-container/routers/api/v1/external-domains.js +++ b/create-a-container/routers/api/v1/external-domains.js @@ -244,7 +244,7 @@ router.get( 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}`); + console.warn(`AUDIT: DKIM private key for ${d.name} exported by admin [redacted]`); return ok(res, { domain: d.name, selector: key.selector, From 27802538a5106bb46ed8a087c2f7f56e8bff3332 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 30 Oct 2026 07:46:48 -0700 Subject: [PATCH 05/10] Add join with Users table in mail view creation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../migrations/20261008000006-create-mail-views.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/create-a-container/migrations/20261008000006-create-mail-views.js b/create-a-container/migrations/20261008000006-create-mail-views.js index 7d616512..21f3f20c 100644 --- a/create-a-container/migrations/20261008000006-create-mail-views.js +++ b/create-a-container/migrations/20261008000006-create-mail-views.js @@ -46,7 +46,8 @@ function createViewsSql(dialect) { const canReceive = `(ed.${q('mailEnabled')} AND ed.${q('mailMxVerified')})`; const joins = `FROM ${q('MailAccounts')} ma ` + - `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')}`; + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')} ` + + `JOIN ${q('Users')} u ON u.${q('uidNumber')} = ma.${q('uidNumber')} AND u.${q('status')} = 'active'`; return [ `CREATE VIEW mail_accounts_v AS ` + From cd5cf9338ad3581283e86dc585ef40aa75dd5f45 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 30 Oct 2026 07:47:29 -0700 Subject: [PATCH 06/10] Modify mail host claim handling for degraded holders Update logic to retain degraded holder's claim for Maildirs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/utils/mail-host.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/create-a-container/utils/mail-host.js b/create-a-container/utils/mail-host.js index fdf541bc..bf79ba22 100644 --- a/create-a-container/utils/mail-host.js +++ b/create-a-container/utils/mail-host.js @@ -59,9 +59,8 @@ async function processMailClaim(agent, { wantsMail, missingBinaries }) { return { status: 'unsupported' }; } if (Array.isArray(missingBinaries) && missingBinaries.length > 0) { - // A holder that lost its packages must also lose the claim — otherwise - // every other agent stays in `conflict` while nothing serves mail. - if (agent.mailHostSince) await agent.update({ mailHostSince: null }); + // Keep a degraded holder's claim: its Maildirs are local, so another + // host must not take over without an explicit mailbox migration. return { status: 'missing_packages', missing: missingBinaries }; } return sequelize.transaction(async (transaction) => { From fd87b965745ca09d84140dcb585ea16cc98824b6 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 30 Oct 2026 07:57:03 -0700 Subject: [PATCH 07/10] Refactor SPF record handling to support multiple records Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/utils/mail-dns.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/create-a-container/utils/mail-dns.js b/create-a-container/utils/mail-dns.js index ac80b692..ea188876 100644 --- a/create-a-container/utils/mail-dns.js +++ b/create-a-container/utils/mail-dns.js @@ -170,7 +170,8 @@ async function checkMailDns({ domain, mailIp, mailHostname, spfInclude, dkimKey, // SPF const txt = await resolveTxtSafe(r, domain); - const spfRecord = txt.find((t) => /^v=spf1(\s|$)/i.test(t)) || null; + const spfRecords = txt.filter((t) => /^v=spf1(\s|$)/i.test(t)); + const spfRecord = spfRecords.length === 1 ? spfRecords[0] : null; result.spf = { pass: !!(spfRecord && spfRecordPasses(spfRecord, { mailIp, spfInclude })), record: spfRecord, From 43ba800380323c2b526515e915ce23b111963295 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Mon, 2 Nov 2026 11:11:45 -0700 Subject: [PATCH 08/10] mail: address high-severity review findings from #502 - Reset DNS verification (flags + check metadata) whenever a domain is renamed, in both PUT and PATCH, and run the mailEnabled=true Check DNS against the name being saved so a rename cannot enable mail using the old name's DNS result. - Preserve omitted mail settings on PUT /settings: mail keys are written only when present in the request (explicit empty string still clears), and the relayhost/SPF-include rule validates the effective saved pair. - Soft-delete mail accounts (paranoid) so MailSuppressions survive delete + recreate of the same address: recreation restores the same row/id, keeping its opt-outs; the account SQL views exclude soft-deleted rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../20261008000002-create-mail-accounts.js | 3 ++ .../20261008000006-create-mail-views.js | 11 ++-- create-a-container/models/external-domain.js | 2 +- create-a-container/models/mail-account.js | 4 ++ create-a-container/openapi.v1.yaml | 10 ++-- .../mailaccounts/__tests__/service.test.js | 18 +++++++ .../resources/mailaccounts/repository.js | 18 ++++++- .../resources/mailaccounts/service.js | 14 +++-- .../routers/api/v1/external-domains.js | 54 ++++++++++++++----- create-a-container/routers/api/v1/settings.js | 35 ++++++++---- .../docs/developers/database-schema.md | 5 +- 11 files changed, 134 insertions(+), 40 deletions(-) diff --git a/create-a-container/migrations/20261008000002-create-mail-accounts.js b/create-a-container/migrations/20261008000002-create-mail-accounts.js index 21e816ab..9a433e77 100644 --- a/create-a-container/migrations/20261008000002-create-mail-accounts.js +++ b/create-a-container/migrations/20261008000002-create-mail-accounts.js @@ -27,6 +27,9 @@ module.exports = { lastRotatedAt: { type: Sequelize.DATE, allowNull: true }, createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false }, + // Soft delete (paranoid) — suppressions must survive delete + recreate + // of the same address, so rows are never hard-deleted by the API. + deletedAt: { type: Sequelize.DATE, allowNull: true }, }); await queryInterface.addIndex('MailAccounts', ['externalDomainId', 'localPart'], { unique: true }); await queryInterface.addIndex('MailAccounts', ['uidNumber']); diff --git a/create-a-container/migrations/20261008000006-create-mail-views.js b/create-a-container/migrations/20261008000006-create-mail-views.js index 21f3f20c..dc3f1e50 100644 --- a/create-a-container/migrations/20261008000006-create-mail-views.js +++ b/create-a-container/migrations/20261008000006-create-mail-views.js @@ -44,6 +44,9 @@ function createViewsSql(dialect) { const address = `LOWER(${cat(`ma.${q('localPart')}`, "'@'", `ed.${q('name')}`)})`; const canSend = `(ed.${q('mailEnabled')} AND ed.${q('mailDnsVerified')})`; const canReceive = `(ed.${q('mailEnabled')} AND ed.${q('mailMxVerified')})`; + // Soft-deleted accounts keep their row (and suppressions) but vanish from + // the account views — no authentication, sending, or delivery. + const live = `ma.${q('deletedAt')} IS NULL`; const joins = `FROM ${q('MailAccounts')} ma ` + `JOIN ${q('ExternalDomains')} ed ON ed.${q('id')} = ma.${q('externalDomainId')} ` + @@ -57,18 +60,20 @@ function createViewsSql(dialect) { `ma.${q('quotaBytes')} AS quota_bytes, ` + `${canSend} AS can_send, ` + `${canReceive} AS can_receive ` + - `${joins} WHERE ma.${q('enabled')}`, + `${joins} WHERE ${live} AND ma.${q('enabled')}`, `CREATE VIEW mail_senders_v AS ` + `SELECT ${address} AS sender, ${address} AS login, ` + `ma.${q('id')} AS account_id, ` + `ma.${q('unsubscribeHeaders')} AS unsubscribe_headers ` + - `${joins} WHERE ma.${q('enabled')} AND ${canSend}`, + `${joins} WHERE ${live} AND ma.${q('enabled')} AND ${canSend}`, `CREATE VIEW mail_recipients_v AS ` + `SELECT ${address} AS recipient ` + - `${joins} WHERE ma.${q('enabled')} AND ${canReceive}`, + `${joins} WHERE ${live} AND ma.${q('enabled')} AND ${canReceive}`, + // Deliberately includes soft-deleted accounts: their suppressions stay + // live so the address cannot shed opt-outs via delete + recreate. `CREATE VIEW mail_suppressions_v AS ` + `SELECT ${address} AS sender, ms.${q('recipient')} AS recipient ` + `FROM ${q('MailSuppressions')} ms ` + diff --git a/create-a-container/models/external-domain.js b/create-a-container/models/external-domain.js index 2127c4f7..0cd57306 100644 --- a/create-a-container/models/external-domain.js +++ b/create-a-container/models/external-domain.js @@ -86,7 +86,7 @@ module.exports = (sequelize) => { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, - comment: 'SPF/DKIM/DMARC check passed. mailEnabled && mailDnsVerified => domain can send' + comment: 'SPF/DKIM/DMARC check passed. mailEnabled && mailDnsVerified => domain can send. Reset (with mailMxVerified and the check metadata) whenever the domain is renamed — see routers/api/v1/external-domains.js' }, mailMxVerified: { type: DataTypes.BOOLEAN, diff --git a/create-a-container/models/mail-account.js b/create-a-container/models/mail-account.js index 882746ab..7ba5fd28 100644 --- a/create-a-container/models/mail-account.js +++ b/create-a-container/models/mail-account.js @@ -85,6 +85,10 @@ module.exports = (sequelize, DataTypes) => { modelName: 'MailAccount', tableName: 'MailAccounts', timestamps: true, + // Soft delete: deleting an account must not cascade away its + // MailSuppressions — recreating the same address restores the row (and + // its opt-outs) instead of minting a suppression-free account. + paranoid: true, indexes: [ { unique: true, fields: ['externalDomainId', 'localPart'] }, { fields: ['uidNumber'] }, diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index e4a44a48..33f293a0 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -465,7 +465,7 @@ components: authServer: { type: string, nullable: true } hasCloudflareApiKey: { type: boolean } mailEnabled: { type: boolean, description: 'Toggled via PATCH /external-domains/{id}; enabling is gated on Check DNS' } - mailDnsVerified: { type: boolean, description: 'SPF+DKIM+DMARC verified. With mailEnabled => domain can send' } + mailDnsVerified: { type: boolean, description: 'SPF+DKIM+DMARC verified. With mailEnabled => domain can send. Reset (with mailMxVerified) when the domain is renamed' } mailMxVerified: { type: boolean, description: 'MX resolves to the mail IP. With mailEnabled => domain can receive' } mailDnsCheckedAt: { type: string, format: date-time, nullable: true } ExternalDomainInput: @@ -1685,7 +1685,7 @@ paths: put: operationId: update_external_domain tags: [External Domains] - summary: Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted). + summary: Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted); renaming resets DNS verification. requestBody: content: application/json: @@ -1701,7 +1701,7 @@ paths: patch: operationId: patch_external_domain tags: [External Domains] - summary: Partial update (admin). Only provided fields change; `mailEnabled: true` runs Check DNS first and requires the send gate (SPF + DKIM + DMARC). + summary: Partial update (admin). Only provided fields change; renaming resets DNS verification; `mailEnabled: true` runs Check DNS against the name being saved and requires the send gate (SPF + DKIM + DMARC). requestBody: content: application/json: @@ -1869,7 +1869,7 @@ paths: delete: operationId: delete_mail_account tags: [Mail] - summary: Delete a mail account (owner or admin) + summary: Delete a mail account (owner or admin). Soft delete — suppressions are preserved and recreating the address restores them. responses: '204': { description: Deleted } '404': { $ref: '#/components/responses/NotFound' } @@ -2212,7 +2212,7 @@ paths: put: operationId: update_settings tags: [Settings] - summary: Save system settings (admin). Full update — omitted fields are cleared. + summary: Save system settings (admin). Full update for the pre-mail fields — omitted ones are cleared. Mail settings are written only when present (an explicit empty string clears); the relayhost/SPF-include rule is validated against the effective saved pair. requestBody: content: application/json: diff --git a/create-a-container/resources/mailaccounts/__tests__/service.test.js b/create-a-container/resources/mailaccounts/__tests__/service.test.js index 94ea1e89..e4e7f0d0 100644 --- a/create-a-container/resources/mailaccounts/__tests__/service.test.js +++ b/create-a-container/resources/mailaccounts/__tests__/service.test.js @@ -41,6 +41,7 @@ beforeEach(() => { repo.findDomain.mockResolvedValue(sendableDomain); repo.findByAddress.mockResolvedValue(null); repo.create.mockImplementation(async (fields) => accountRow(fields)); + repo.restore.mockImplementation(async (account, fields) => ({ ...account, ...fields, deletedAt: null })); repo.update.mockImplementation(async (account, fields) => ({ ...account, ...fields })); }); @@ -81,6 +82,23 @@ describe('createAccount', () => { .rejects.toMatchObject({ status: 409, code: 'address_taken' }); }); + test('recreating a soft-deleted address restores it so suppressions survive', async () => { + const deleted = accountRow({ deletedAt: new Date(), enabled: false }); + repo.findByAddress.mockResolvedValue(deleted); + const { account, password } = await svc.createAccount(session, { externalDomainId: 1, localPart: 'app' }); + expect(password).toHaveLength(43); + expect(repo.create).not.toHaveBeenCalled(); + const [restored, fields] = repo.restore.mock.calls[0]; + expect(restored).toBe(deleted); + expect(fields).toMatchObject({ + uidNumber: user.uidNumber, + enabled: true, + unsubscribeHeaders: true, + lastRotatedAt: null, + }); + expect(account.id).toBe(deleted.id); + }); + test('only admins can set a quota at create', async () => { await expect(svc.createAccount(session, { externalDomainId: 1, localPart: 'app', quotaMb: 10 })) .rejects.toMatchObject({ status: 403, code: 'admin_required' }); diff --git a/create-a-container/resources/mailaccounts/repository.js b/create-a-container/resources/mailaccounts/repository.js index ee52d56a..dba9114c 100644 --- a/create-a-container/resources/mailaccounts/repository.js +++ b/create-a-container/resources/mailaccounts/repository.js @@ -19,8 +19,11 @@ async function findById(id) { return MailAccount.findByPk(id, { include: INCLUDES }); } -async function findByAddress(externalDomainId, localPart) { - return MailAccount.findOne({ where: { externalDomainId, localPart } }); +async function findByAddress(externalDomainId, localPart, { includeDeleted = false } = {}) { + return MailAccount.findOne({ + where: { externalDomainId, localPart }, + paranoid: !includeDeleted, + }); } async function create(fields) { @@ -28,12 +31,22 @@ async function create(fields) { return findById(account.id); } +/** Bring a soft-deleted account back under a fresh owner/credentials while + * keeping its id — and therefore its MailSuppressions. */ +async function restore(account, fields) { + await account.restore(); + await account.update(fields); + return findById(account.id); +} + async function update(account, fields) { await account.update(fields); return findById(account.id); } async function destroy(account) { + // Paranoid model — soft delete. Suppressions survive so recreating the + // address cannot shed its opt-outs (see service.createAccount). return account.destroy(); } @@ -79,6 +92,7 @@ module.exports = { findById, findByAddress, create, + restore, update, destroy, findDomain, diff --git a/create-a-container/resources/mailaccounts/service.js b/create-a-container/resources/mailaccounts/service.js index d1bcc1fc..d1b8ade4 100644 --- a/create-a-container/resources/mailaccounts/service.js +++ b/create-a-container/resources/mailaccounts/service.js @@ -81,20 +81,27 @@ async function createAccount(session, { externalDomainId, localPart, description if (quotaMb !== undefined && !session.isAdmin) { throw new ApiError(403, 'admin_required', 'Only admins can set a quota'); } - if (await repo.findByAddress(externalDomainId, localPart)) { + // Accounts are soft-deleted so a deleted address keeps its suppressions. + // Recreating it restores the same row (same id) — delete + recreate cannot + // bypass the admin-only suppression-removal rule. + const existing = await repo.findByAddress(externalDomainId, localPart, { includeDeleted: true }); + if (existing && !existing.deletedAt) { throw new ApiError(409, 'address_taken', 'That address already exists'); } const defaultQuotaMb = parseInt(await repo.getSetting('mail_default_quota_mb'), 10) || DEFAULT_QUOTA_MB; const password = generateMailPassword(); - const account = await repo.create({ + const fields = { uidNumber: user.uidNumber, externalDomainId, localPart, description: description || null, passwordHash: await hashMailPassword(password), quotaBytes: (quotaMb ?? defaultQuotaMb) * MB, - }); + }; + const account = existing + ? await repo.restore(existing, { ...fields, enabled: true, unsubscribeHeaders: true, lastRotatedAt: null }) + : await repo.create(fields); return { account, password, connection: await connectionInfo(account) }; } @@ -132,6 +139,7 @@ async function rotatePassword(session, id) { return { account: updated, password, connection: await connectionInfo(updated) }; } +/** Soft delete — the row (and its suppressions) survives for recreation. */ async function deleteAccount(session, id) { const user = await requireUser(session); const account = await loadAccountFor(session, id, user); diff --git a/create-a-container/routers/api/v1/external-domains.js b/create-a-container/routers/api/v1/external-domains.js index b9d50889..09434174 100644 --- a/create-a-container/routers/api/v1/external-domains.js +++ b/create-a-container/routers/api/v1/external-domains.js @@ -2,7 +2,8 @@ * /api/v1/external-domains — admin-only CRUD + per-domain mail enablement. * The Cloudflare API key is write-only: never returned in any response. * mailEnabled toggles via PATCH /:id; the verification flags are read-only - * (they change only via POST /:id/mail/check-dns). + * (they change only via POST /:id/mail/check-dns, and reset when the domain + * is renamed — the saved result belongs to the old name). */ const express = require('express'); @@ -17,6 +18,15 @@ const router = express.Router(); router.use(apiAuth, apiAdmin); +// Renaming invalidates DNS verification — SPF/DKIM/DMARC/MX were checked +// against the old name, so the new name must be re-verified. +const MAIL_VERIFICATION_RESET = { + mailDnsVerified: false, + mailMxVerified: false, + mailDnsCheckedAt: null, + mailDnsCheckResult: null, +}; + function serialize(d) { return { id: d.id, @@ -103,6 +113,9 @@ router.put( if (cloudflareApiKey && cloudflareApiKey.trim() !== '') { update.cloudflareApiKey = cloudflareApiKey; } + if (name !== undefined && name !== d.name) { + Object.assign(update, MAIL_VERIFICATION_RESET); + } await d.update(update); return ok(res, serialize(d)); }), @@ -127,14 +140,23 @@ router.patch( update.cloudflareApiKey = body.cloudflareApiKey; } + const renaming = update.name !== undefined && update.name !== d.name; + if (renaming) Object.assign(update, MAIL_VERIFICATION_RESET); + let check; if (body.mailEnabled === true) { - check = await runDnsCheck(d); - if (!d.mailDnsVerified) { + // Check the name being saved — a rename must not enable mail using the + // old name's DNS result. + check = await runDnsCheck(d, renaming ? update.name : d.name); + const checkFields = dnsCheckFields(check); + if (!checkFields.mailDnsVerified) { + // Record the failed check unless it ran against a name that is + // not being saved (the 422 aborts the rename). + if (!renaming) await d.update(checkFields); throw new ApiError(422, 'dns_not_configured', 'SPF, DKIM, or DMARC records are missing or wrong — publish the records from GET …/mail/dns and re-check'); } - update.mailEnabled = true; + Object.assign(update, checkFields, { mailEnabled: true }); } else if (body.mailEnabled === false) { update.mailEnabled = false; } @@ -166,7 +188,7 @@ async function loadDomain(id) { } /** Everything buildMailDnsRecords/checkMailDns need for one domain. */ -async function mailDnsContext(d) { +async function mailDnsContext(d, name = d.name) { const settings = await Setting.getMultiple([ 'mail_hostname', 'mail_spf_include', 'mail_dns_check_resolvers', ]); @@ -175,7 +197,7 @@ async function mailDnsContext(d) { order: [['createdAt', 'DESC']], }); return { - domain: d.name, + domain: name, mailIp: await getMailIp(), mailHostname: settings.mail_hostname || null, spfInclude: settings.mail_spf_include || null, @@ -185,9 +207,10 @@ async function mailDnsContext(d) { }; } -/** Run Check DNS, persist the outcome, and flip the verification gates. */ -async function runDnsCheck(d) { - const context = await mailDnsContext(d); +/** Run Check DNS for `name` (defaults to the saved name) and return the + * result — persist it with dnsCheckFields(). */ +async function runDnsCheck(d, name = d.name) { + const context = await mailDnsContext(d, name); const result = await checkMailDns(context); const accounts = await MailAccount.findAll({ where: { externalDomainId: d.id, localPart: ['postmaster', 'abuse'] }, @@ -195,16 +218,20 @@ async function runDnsCheck(d) { }); for (const role of ['postmaster', 'abuse']) { if (!accounts.some((a) => a.localPart === role)) { - result.warnings.push(`No ${role}@${d.name} mailbox exists yet (admins create role mailboxes).`); + result.warnings.push(`No ${role}@${name} mailbox exists yet (admins create role mailboxes).`); } } - await d.update({ + return result; +} + +/** Verification gate fields derived from one Check DNS result. */ +function dnsCheckFields(result) { + return { mailDnsCheckedAt: new Date(), mailDnsCheckResult: result, mailDnsVerified: result.spf.pass && result.dkim.pass && result.dmarc.pass, mailMxVerified: result.mx.pass, - }); - return result; + }; } router.get( @@ -229,6 +256,7 @@ router.post( asyncHandler(async (req, res) => { const d = await loadDomain(req.params.id); const result = await runDnsCheck(d); + await d.update(dnsCheckFields(result)); return ok(res, { ...serialize(d), check: result }); }), ); diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index e6a3f886..2e1a11d2 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -90,7 +90,14 @@ router.put( } = req.body || {}; // A relayhost sends from the relay's IPs, so SPF must delegate to it. - if ((mailRelayhost || '').trim() && !(mailSpfInclude || '').trim()) { + // Validate the pair that will be in effect after this save — mail fields + // omitted from the request keep their stored values. + const stored = await Setting.getMultiple(['mail_relayhost', 'mail_spf_include']); + const effectiveRelayhost = + mailRelayhost !== undefined ? (mailRelayhost || '').trim() : (stored.mail_relayhost || ''); + const effectiveSpfInclude = + mailSpfInclude !== undefined ? (mailSpfInclude || '').trim() : (stored.mail_spf_include || ''); + if (effectiveRelayhost && !effectiveSpfInclude) { return fail(res, 422, 'validation_failed', 'mailSpfInclude is required when a relayhost is set'); } @@ -124,16 +131,22 @@ router.put( const n = parseInt(String(value ?? '').trim(), 10); return Number.isNaN(n) || n < 1 ? '' : String(n); }; - await Setting.set('mail_hostname', (mailHostname || '').trim()); - await Setting.set('mail_relayhost', (mailRelayhost || '').trim()); - await Setting.set('mail_relayhost_username', (mailRelayhostUsername || '').trim()); - await Setting.set('mail_relayhost_password', mailRelayhostPassword || ''); - await Setting.set('mail_spf_include', (mailSpfInclude || '').trim()); - await Setting.set('mail_dns_check_resolvers', (mailDnsCheckResolvers || '').trim()); - await Setting.set('mail_db_host', (mailDbHost || '').trim()); - await Setting.set('mail_self_managed_site_id', positiveIntOrEmpty(mailSelfManagedSiteId)); - await Setting.set('mail_default_quota_mb', positiveIntOrEmpty(mailDefaultQuotaMb)); - await Setting.set('mail_message_size_limit_mb', positiveIntOrEmpty(mailMessageSizeLimitMb)); + // Mail settings are written only when present in the request so clients + // that predate them (the existing Settings page) cannot clear them — + // an explicit empty string still clears a value. + const setIfProvided = async (key, value, normalize = (v) => (v || '').trim()) => { + if (value !== undefined) await Setting.set(key, normalize(value)); + }; + await setIfProvided('mail_hostname', mailHostname); + await setIfProvided('mail_relayhost', mailRelayhost); + await setIfProvided('mail_relayhost_username', mailRelayhostUsername); + await setIfProvided('mail_relayhost_password', mailRelayhostPassword, (v) => v || ''); + await setIfProvided('mail_spf_include', mailSpfInclude); + await setIfProvided('mail_dns_check_resolvers', mailDnsCheckResolvers); + await setIfProvided('mail_db_host', mailDbHost); + await setIfProvided('mail_self_managed_site_id', mailSelfManagedSiteId, positiveIntOrEmpty); + await setIfProvided('mail_default_quota_mb', mailDefaultQuotaMb, positiveIntOrEmpty); + await setIfProvided('mail_message_size_limit_mb', mailMessageSizeLimitMb, positiveIntOrEmpty); return ok(res, { saved: true }); }), diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 62c35044..fd1911c3 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -168,6 +168,7 @@ erDiagram bigint quotaBytes boolean unsubscribeHeaders "default true (RFC 8058)" date lastRotatedAt + date deletedAt "paranoid soft delete; keeps suppressions" } MailSuppressions { @@ -265,7 +266,7 @@ Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Co Persistent bind-mount attached to a container (issue #421). Unique composite indexes on `(containerId, name)` and `(containerId, mountPath)`. `hostPath` is derived server-side from the node volume storage's actual configured `path` (`/volumes/site-///`; scoped by site so containers with the same hostname across sites don't collide on shared storage, and by owner so retained data is only reattached for the same owner) and is null until the create job derives it. `mode` is `ro`/`rw`; each row renders to a Proxmox `mpN` bind mount (`Volume.buildMountConfig`). `status` (`pending` → `ready` | `failed`) is the readiness barrier the create/reconfigure jobs block on — the site agent creates the host directory and reports the result at check-in, which the manager writes to `status`/`statusMessage`/`appliedAt`. `builtin` marks the retired `quick_and_dirty` mount backfilled onto pre-#421 containers for reference (never re-applied; its live `mp0` slot is reserved by `buildMountConfig`). Belongs to Container. See [Volumes](../admins/core-concepts/volumes.md). ### ExternalDomain -Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). Mail gates (`mailEnabled`, `mailDnsVerified`, `mailMxVerified`) are changed only via the `…/mail/*` endpoints; has many DkimKeys and MailAccounts. +Manages public domains for HTTP service exposure. `siteId` is nullable — when set, indicates the "default site" whose DNS is assumed pre-configured (e.g., wildcard A record). Global resource available to all sites. Has many HTTPServices. Cloudflare credentials used for both ACME DNS-01 challenges and cross-site A record management. `authServer` is an optional address of an [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) process (e.g. `http://127.0.0.1:4180`) that NGINX proxies `/oauth2/*` to for `auth_request` (see [External Domains](../admins/core-concepts/external-domains.md#authentication)). Mail gates (`mailEnabled`, `mailDnsVerified`, `mailMxVerified`) are changed only via the `…/mail/*` endpoints and reset on rename (DNS results belong to the old name); has many DkimKeys and MailAccounts. ## Mail Models (issue #67) @@ -273,7 +274,7 @@ Manages public domains for HTTP service exposure. `siteId` is nullable — when RSA-2048 signing key per external domain, generated at domain creation (selector `osYYYYMMDD`). `status` supports future rotation. The private key is never serialized in API responses — it reaches only the mail-host agent snapshot and the admin DKIM export. ### MailAccount -A user-owned SMTP/IMAP account on a can-send domain. Password stored as an Argon2id PHC string with `p=1` (Dovecot verifies via libsodium, which only supports parallelism 1); the plaintext is returned exactly once at create/rotate. `enabled` gates the SQL views, so disabling locks the account out instantly. +A user-owned SMTP/IMAP account on a can-send domain. Password stored as an Argon2id PHC string with `p=1` (Dovecot verifies via libsodium, which only supports parallelism 1); the plaintext is returned exactly once at create/rotate. `enabled` gates the SQL views, so disabling locks the account out instantly. Deletes are soft (`paranoid`, `deletedAt`): the row and its MailSuppressions survive, the account views exclude it, and recreating the same address restores the row — delete + recreate cannot shed opt-outs. ### MailSuppression A recipient who one-click-unsubscribed (RFC 8058) from a MailAccount, or was suppressed by an admin. Enforced synchronously at RCPT on the submission ports. Unsubscribe tokens are stateless — AES-256-GCM keys are HKDF-derived from the `SessionSecrets` rows (newest mints; older ones keep old links verifying), so there is no key table. From 5094a3c3cc55a521210cdc2f55cea281a0a51c08 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Mon, 2 Nov 2026 11:46:10 -0700 Subject: [PATCH 09/10] Enhance address existence check for non-admin users Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/resources/mailaccounts/service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create-a-container/resources/mailaccounts/service.js b/create-a-container/resources/mailaccounts/service.js index d1b8ade4..651aa742 100644 --- a/create-a-container/resources/mailaccounts/service.js +++ b/create-a-container/resources/mailaccounts/service.js @@ -85,7 +85,7 @@ async function createAccount(session, { externalDomainId, localPart, description // Recreating it restores the same row (same id) — delete + recreate cannot // bypass the admin-only suppression-removal rule. const existing = await repo.findByAddress(externalDomainId, localPart, { includeDeleted: true }); - if (existing && !existing.deletedAt) { + if (existing && (!existing.deletedAt || (!session.isAdmin && existing.uidNumber !== user.uidNumber))) { throw new ApiError(409, 'address_taken', 'That address already exists'); } From 42e2413736c3d5d8d8d580a2eaec7e4b2f217b45 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Mon, 2 Nov 2026 11:46:16 -0700 Subject: [PATCH 10/10] Use transaction in restore function for account Refactor restore function to use a transaction for restoring and updating the account. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/resources/mailaccounts/repository.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/create-a-container/resources/mailaccounts/repository.js b/create-a-container/resources/mailaccounts/repository.js index dba9114c..73e50041 100644 --- a/create-a-container/resources/mailaccounts/repository.js +++ b/create-a-container/resources/mailaccounts/repository.js @@ -34,9 +34,11 @@ async function create(fields) { /** Bring a soft-deleted account back under a fresh owner/credentials while * keeping its id — and therefore its MailSuppressions. */ async function restore(account, fields) { - await account.restore(); - await account.update(fields); - return findById(account.id); + return MailAccount.sequelize.transaction(async (transaction) => { + await account.restore({ transaction }); + await account.update(fields, { transaction }); + return MailAccount.findByPk(account.id, { include: INCLUDES, transaction }); + }); } async function update(account, fields) {