Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions create-a-container/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
},
};
30 changes: 30 additions & 0 deletions create-a-container/migrations/20261008000001-create-dkim-keys.js
Original file line number Diff line number Diff line change
@@ -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";');
}
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'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 },
// 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']);
},

async down(queryInterface) {
await queryInterface.dropTable('MailAccounts');
},
};
Original file line number Diff line number Diff line change
@@ -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');
},
};
Original file line number Diff line number Diff line change
@@ -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";');
}
},
};
128 changes: 128 additions & 0 deletions create-a-container/migrations/20261008000006-create-mail-views.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use strict';

/**
* The mail SQL views — the stable contract Dovecot/Postfix (or a
* 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')})`;
// 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')} ` +
`JOIN ${q('Users')} u ON u.${q('uidNumber')} = ma.${q('uidNumber')} AND u.${q('status')} = 'active'`;

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 ${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 ${live} AND ma.${q('enabled')} AND ${canSend}`,

`CREATE VIEW mail_recipients_v AS ` +
`SELECT ${address} AS recipient ` +
`${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 ` +
`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();
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);
}
},
};
36 changes: 36 additions & 0 deletions create-a-container/migrations/20261008000007-backfill-dkim-keys.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
Loading
Loading