diff --git a/CHANGES b/CHANGES index 6e7b0e6be..e94a7611c 100644 --- a/CHANGES +++ b/CHANGES @@ -1918,4 +1918,5 @@ * Add members only flag to mailing list * oauth: add backward compat layer for backup and uninstall * fix bug in disk usage sorting +* mail: aliases can be across domains diff --git a/migrations/20200420013715-mailboxes-add-aliasDomain.js b/migrations/20200420013715-mailboxes-add-aliasDomain.js new file mode 100644 index 000000000..083824d04 --- /dev/null +++ b/migrations/20200420013715-mailboxes-add-aliasDomain.js @@ -0,0 +1,28 @@ +'use strict'; + +var async = require('async'); + +exports.up = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE mailboxes ADD COLUMN aliasDomain VARCHAR(128)'), + function setAliasDomain(done) { + db.all('SELECT * FROM mailboxes', function (error, mailboxes) { + async.eachSeries(mailboxes, function (mailbox, iteratorDone) { + if (!mailbox.aliasTarget) return iteratorDone(); + + db.runSql('UPDATE mailboxes SET aliasDomain=? WHERE name=? AND domain=?', [ mailbox.domain, mailbox.name, mailbox.domain ], iteratorDone); + }, done); + }); + }, + db.runSql.bind(db, 'ALTER TABLE mailboxes ADD CONSTRAINT mailboxes_aliasDomain_constraint FOREIGN KEY(aliasDomain) REFERENCES mail(domain)'), + db.runSql.bind(db, 'ALTER TABLE mailboxes CHANGE aliasTarget aliasName VARCHAR(128)') + ], callback); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE mailboxes DROP FOREIGN KEY mailboxes_aliasDomain_constraint'), + db.runSql.bind(db, 'ALTER TABLE mailboxes DROP COLUMN aliasDomain'), + db.runSql.bind(db, 'ALTER TABLE mailboxes CHANGE aliasName aliasTarget VARCHAR(128)') + ], callback); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 7ad0b5bb8..e09d7c08d 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -177,13 +177,15 @@ CREATE TABLE IF NOT EXISTS mailboxes( name VARCHAR(128) NOT NULL, type VARCHAR(16) NOT NULL, /* 'mailbox', 'alias', 'list' */ ownerId VARCHAR(128) NOT NULL, /* user id */ - aliasTarget VARCHAR(128), /* the target name type is an alias */ + aliasName VARCHAR(128), /* the target name type is an alias */ + aliasDomain VARCHAR(128), /* the target domain */ membersJson TEXT, /* members of a group. fully qualified */ membersOnly BOOLEAN DEFAULT false, creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, domain VARCHAR(128), FOREIGN KEY(domain) REFERENCES mail(domain), + FOREIGN KEY(aliasDomain) REFERENCES mail(domain), UNIQUE (name, domain)); CREATE TABLE IF NOT EXISTS subdomains( diff --git a/src/infra_version.js b/src/infra_version.js index b0972a5c2..8238c44ef 100644 --- a/src/infra_version.js +++ b/src/infra_version.js @@ -20,7 +20,7 @@ exports = module.exports = { 'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:2.0.2@sha256:6dcee0731dfb9b013ed94d56205eee219040ee806c7e251db3b3886eaa4947ff' }, 'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:2.1.0@sha256:6d1bf221cfe6124957e2c58b57c0a47214353496009296acb16adf56df1da9d5' }, 'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:2.1.0@sha256:f2cda21bd15c21bbf44432df412525369ef831a2d53860b5c5b1675e6f384de2' }, - 'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:2.8.0@sha256:5bbc6d5082c96622de9f40625266fb7974bfa6b6a92f043421c9872f9f71b357' }, + 'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:2.8.0@sha256:7830a1c4e00687add7567d7adaf72fb19ff6ff3864e77076540a3ac40e31cef6' }, 'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:2.2.0@sha256:fc9ca69d16e6ebdbd98ed53143d4a0d2212eef60cb638dc71219234e6f427a2c' }, 'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:1.0.0@sha256:3b70aac36700225945a4a39b5a400c28e010e980879d0dcca76e4a37b04a16ed' } } diff --git a/src/ldap.js b/src/ldap.js index d05dabb98..962e02717 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -391,7 +391,7 @@ function mailAliasSearch(req, res, next) { objectclass: ['nisMailAlias'], objectcategory: 'nisMailAlias', cn: `${alias.name}@${alias.domain}`, - rfc822MailMember: `${alias.aliasTarget}@${alias.domain}` + rfc822MailMember: `${alias.aliasName}@${alias.aliasDomain}` } }; diff --git a/src/mail.js b/src/mail.js index 3c2ca5ee9..2cc405377 100644 --- a/src/mail.js +++ b/src/mail.js @@ -1163,10 +1163,14 @@ function setAliases(name, domain, aliases, callback) { assert.strictEqual(typeof callback, 'function'); for (var i = 0; i < aliases.length; i++) { - aliases[i] = aliases[i].toLowerCase(); + let name = aliases[i].name.toLowerCase(); + let domain = aliases[i].domain.toLowerCase(); - var error = validateName(aliases[i]); + let error = validateName(name); if (error) return callback(error); + + if (!validator.isEmail(`${name}@${domain}`)) return callback(new BoxError(BoxError.BAD_FIELD, `Invalid email: ${name}@${domain}`)); + aliases[i] = { name, domain }; } mailboxdb.setAliasesForName(name, domain, aliases, function (error) { @@ -1270,6 +1274,7 @@ function removeList(name, domain, auditSource, callback) { }); } +// resolves the members of a list. i.e the lists and aliases function resolveList(listName, listDomain, callback) { assert.strictEqual(typeof listName, 'string'); assert.strictEqual(typeof listDomain, 'string'); @@ -1300,14 +1305,17 @@ function resolveList(listName, listDomain, callback) { visited.push(member); mailboxdb.get(memberName, memberDomain, function (error, entry) { - if (error && error.reason == BoxError.NOT_FOUND) { result.push(member); return iteratorCallback(); } + if (error && error.reason == BoxError.NOT_FOUND) { result.push(member); return iteratorCallback(); } // let it bounce if (error) return iteratorCallback(error); - if (entry.type === mailboxdb.TYPE_MAILBOX) { result.push(member); return iteratorCallback(); } - // no need to resolve alias because we only allow one level and within same domain - if (entry.type === mailboxdb.TYPE_ALIAS) { result.push(`${entry.aliasTarget}@${entry.domain}`); return iteratorCallback(); } + if (entry.type === mailboxdb.TYPE_MAILBOX) { // concrete mailbox + result.push(member); + } else if (entry.type === mailboxdb.TYPE_ALIAS) { // resolve aliases + toResolve = toResolve.concat(`${entry.aliasName}@${entry.aliasTarget}`); + } else { // resolve list members + toResolve = toResolve.concat(entry.members); + } - toResolve = toResolve.concat(entry.members); iteratorCallback(); }); }, function (error) { diff --git a/src/mailboxdb.js b/src/mailboxdb.js index 1c53d1b66..e18c1cec1 100644 --- a/src/mailboxdb.js +++ b/src/mailboxdb.js @@ -41,7 +41,7 @@ var assert = require('assert'), safe = require('safetydance'), util = require('util'); -var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'aliasTarget', 'creationTime', 'membersJson', 'membersOnly', 'domain' ].join(','); +var MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain' ].join(','); function postProcess(data) { data.members = safe.JSON.parse(data.membersJson) || [ ]; @@ -127,7 +127,7 @@ function del(name, domain, callback) { assert.strictEqual(typeof callback, 'function'); // deletes aliases as well - database.query('DELETE FROM mailboxes WHERE (name=? OR aliasTarget = ?) AND domain = ?', [ name, name, domain ], function (error, result) { + database.query('DELETE FROM mailboxes WHERE ((name=? AND domain=?) OR (aliasName = ? AND aliasDomain=?))', [ name, domain, name, domain ], function (error, result) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); if (result.affectedRows === 0) return callback(new BoxError(BoxError.NOT_FOUND, 'Mailbox not found')); @@ -289,10 +289,10 @@ function setAliasesForName(name, domain, aliases, callback) { var queries = []; // clear existing aliases - queries.push({ query: 'DELETE FROM mailboxes WHERE aliasTarget = ? AND domain = ? AND type = ?', args: [ name, domain, exports.TYPE_ALIAS ] }); + queries.push({ query: 'DELETE FROM mailboxes WHERE aliasName = ? AND aliasDomain = ? AND type = ?', args: [ name, domain, exports.TYPE_ALIAS ] }); aliases.forEach(function (alias) { - queries.push({ query: 'INSERT INTO mailboxes (name, type, domain, aliasTarget, ownerId) VALUES (?, ?, ?, ?, ?)', - args: [ alias, exports.TYPE_ALIAS, domain, name, results[0].ownerId ] }); + queries.push({ query: 'INSERT INTO mailboxes (name, domain, type, aliasName, aliasDomain, ownerId) VALUES (?, ?, ?, ?, ?, ?)', + args: [ alias.name, alias.domain, exports.TYPE_ALIAS, name, domain, results[0].ownerId ] }); }); database.transaction(queries, function (error) { @@ -315,11 +315,10 @@ function getAliasesForName(name, domain, callback) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof callback, 'function'); - database.query('SELECT name FROM mailboxes WHERE type = ? AND aliasTarget = ? AND domain = ? ORDER BY name', + database.query('SELECT name, domain FROM mailboxes WHERE type = ? AND aliasName = ? AND aliasDomain = ? ORDER BY name', [ exports.TYPE_ALIAS, name, domain ], function (error, results) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); - results = results.map(function (r) { return r.name; }); callback(null, results); }); } diff --git a/src/routes/mail.js b/src/routes/mail.js index 0fdbc63d9..e78584361 100644 --- a/src/routes/mail.js +++ b/src/routes/mail.js @@ -249,8 +249,10 @@ function setAliases(req, res, next) { if (!Array.isArray(req.body.aliases)) return next(new HttpError(400, 'aliases must be an array')); - for (var i = 0; i < req.body.aliases.length; i++) { - if (typeof req.body.aliases[i] !== 'string') return next(new HttpError(400, 'alias must be a string')); + for (let alias of req.body.aliases) { + if (!alias || typeof alias !== 'object') return next(new HttpError(400, 'each alias must have a name and domain')); + if (typeof alias.name !== 'string') return next(new HttpError(400, 'name must be a string')); + if (typeof alias.domain !== 'string') return next(new HttpError(400, 'domain must be a string')); } mail.setAliases(req.params.name, req.params.domain, req.body.aliases, function (error) { diff --git a/src/routes/test/mail-test.js b/src/routes/test/mail-test.js index 0e4761a78..30848da08 100644 --- a/src/routes/test/mail-test.js +++ b/src/routes/test/mail-test.js @@ -600,7 +600,8 @@ describe('Mail API', function () { expect(res.body.mailbox).to.be.an('object'); expect(res.body.mailbox.name).to.equal(MAILBOX_NAME); expect(res.body.mailbox.ownerId).to.equal(userId); - expect(res.body.mailbox.aliasTarget).to.equal(null); + expect(res.body.mailbox.aliasName).to.equal(null); + expect(res.body.mailbox.aliasDomain).to.equal(null); expect(res.body.mailbox.domain).to.equal(DOMAIN_0.domain); done(); }); @@ -615,7 +616,8 @@ describe('Mail API', function () { expect(res.body.mailboxes[0]).to.be.an('object'); expect(res.body.mailboxes[0].name).to.equal(MAILBOX_NAME); expect(res.body.mailboxes[0].ownerId).to.equal(userId); - expect(res.body.mailboxes[0].aliasTarget).to.equal(null); + expect(res.body.mailboxes[0].aliasName).to.equal(null); + expect(res.body.mailboxes[0].aliasDomain).to.equal(null); expect(res.body.mailboxes[0].domain).to.equal(DOMAIN_0.domain); done(); }); @@ -664,7 +666,7 @@ describe('Mail API', function () { it('set fails if user does not exist', function (done) { superagent.put(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/aliases/' + 'someuserdoesnotexist') - .send({ aliases: ['hello', 'there'] }) + .send({ aliases: [{ name: 'hello', domain: DOMAIN_0.domain}, {name: 'there', domain: DOMAIN_0.domain}] }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(404); @@ -684,7 +686,7 @@ describe('Mail API', function () { it('set fails if user is not enabled', function (done) { superagent.put(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/aliases/' + MAILBOX_NAME) - .send({ aliases: ['hello', 'there'] }) + .send({ aliases: [{ name: 'hello', domain: DOMAIN_0.domain}, {name: 'there', domain: DOMAIN_0.domain}] }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(404); @@ -704,7 +706,7 @@ describe('Mail API', function () { it('set succeeds', function (done) { superagent.put(SERVER_URL + '/api/v1/mail/' + DOMAIN_0.domain + '/aliases/' + MAILBOX_NAME) - .send({ aliases: ['hello', 'there'] }) + .send({ aliases: [{ name: 'hello', domain: DOMAIN_0.domain}, {name: 'there', domain: DOMAIN_0.domain}] }) .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(202); @@ -717,7 +719,7 @@ describe('Mail API', function () { .query({ access_token: token }) .end(function (err, res) { expect(res.statusCode).to.equal(200); - expect(res.body.aliases).to.eql(['hello', 'there']); + expect(res.body.aliases).to.eql([{ name: 'hello', domain: DOMAIN_0.domain}, {name: 'there', domain: DOMAIN_0.domain}]); done(); }); }); @@ -729,12 +731,15 @@ describe('Mail API', function () { expect(res.statusCode).to.equal(200); expect(res.body.aliases.length).to.eql(2); expect(res.body.aliases[0].name).to.equal('hello'); - expect(res.body.aliases[0].ownerId).to.equal(userId); - expect(res.body.aliases[0].aliasTarget).to.equal(MAILBOX_NAME); expect(res.body.aliases[0].domain).to.equal(DOMAIN_0.domain); + expect(res.body.aliases[0].ownerId).to.equal(userId); + expect(res.body.aliases[0].aliasName).to.equal(MAILBOX_NAME); + expect(res.body.aliases[0].aliasDomain).to.equal(DOMAIN_0.domain); expect(res.body.aliases[1].name).to.equal('there'); + expect(res.body.aliases[1].domain).to.equal(DOMAIN_0.domain); expect(res.body.aliases[1].ownerId).to.equal(userId); - expect(res.body.aliases[1].aliasTarget).to.equal(MAILBOX_NAME); + expect(res.body.aliases[1].aliasName).to.equal(MAILBOX_NAME); + expect(res.body.aliases[1].aliasDomain).to.equal(DOMAIN_0.domain); expect(res.body.aliases[1].domain).to.equal(DOMAIN_0.domain); done(); }); @@ -825,7 +830,7 @@ describe('Mail API', function () { expect(res.body.list).to.be.an('object'); expect(res.body.list.name).to.equal(LIST_NAME); expect(res.body.list.ownerId).to.equal('admin'); - expect(res.body.list.aliasTarget).to.equal(null); + expect(res.body.list.aliasName).to.equal(null); expect(res.body.list.domain).to.equal(DOMAIN_0.domain); expect(res.body.list.members).to.eql([ `admin2@${DOMAIN_0.domain}`, `superadmin@${DOMAIN_0.domain}` ]); expect(res.body.list.membersOnly).to.be(false); @@ -842,7 +847,7 @@ describe('Mail API', function () { expect(res.body.lists.length).to.equal(1); expect(res.body.lists[0].name).to.equal(LIST_NAME); expect(res.body.lists[0].ownerId).to.equal('admin'); - expect(res.body.lists[0].aliasTarget).to.equal(null); + expect(res.body.lists[0].aliasName).to.equal(null); expect(res.body.lists[0].domain).to.equal(DOMAIN_0.domain); expect(res.body.lists[0].members).to.eql([ `admin2@${DOMAIN_0.domain}`, `superadmin@${DOMAIN_0.domain}` ]); expect(res.body.lists[0].membersOnly).to.be(false); diff --git a/src/test/database-test.js b/src/test/database-test.js index 309cf7290..010a8bd84 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -1829,7 +1829,7 @@ describe('database', function () { }); it('can set alias', function (done) { - mailboxdb.setAliasesForName('support', DOMAIN_0.domain, [ 'support2', 'help' ], function (error) { + mailboxdb.setAliasesForName('support', DOMAIN_0.domain, [ { name: 'support2', domain: DOMAIN_0.domain }, { name: 'help', domain: DOMAIN_0.domain } ], function (error) { expect(error).to.be(null); done(); }); @@ -1839,8 +1839,10 @@ describe('database', function () { mailboxdb.getAliasesForName('support', DOMAIN_0.domain, function (error, results) { expect(error).to.be(null); expect(results.length).to.be(2); - expect(results[0]).to.be('help'); - expect(results[1]).to.be('support2'); + expect(results[0].name).to.be('help'); + expect(results[0].domain).to.be(DOMAIN_0.domain); + expect(results[1].name).to.be('support2'); + expect(results[1].domain).to.be(DOMAIN_0.domain); done(); }); }); @@ -1849,7 +1851,8 @@ describe('database', function () { mailboxdb.getAlias('support2', DOMAIN_0.domain, function (error, result) { expect(error).to.be(null); expect(result.name).to.be('support2'); - expect(result.aliasTarget).to.be('support'); + expect(result.aliasName).to.be('support'); + expect(result.aliasDomain).to.be(DOMAIN_0.domain); done(); }); }); @@ -1859,7 +1862,8 @@ describe('database', function () { expect(error).to.be(null); expect(results.length).to.be(2); expect(results[0].name).to.be('help'); - expect(results[0].aliasTarget).to.be('support'); + expect(results[0].aliasName).to.be('support'); + expect(results[0].aliasDomain).to.be(DOMAIN_0.domain); expect(results[1].name).to.be('support2'); done(); }); diff --git a/src/test/ldap-test.js b/src/test/ldap-test.js index 7ccfc9fd0..d43c4e9ba 100644 --- a/src/test/ldap-test.js +++ b/src/test/ldap-test.js @@ -19,6 +19,7 @@ var appdb = require('../appdb.js'), maildb = require('../maildb.js'), mailboxdb = require('../mailboxdb.js'), ldap = require('ldapjs'), + settings = require('../settings.js'), users = require('../users.js'); const DOMAIN_0 = { @@ -87,6 +88,7 @@ function setup(done) { database.initialize.bind(null), database._clear.bind(null), ldapServer.start.bind(null), + settings.setAdmin.bind(null, DOMAIN_0.domain, 'my.' + DOMAIN_0.domain), domains.add.bind(null, DOMAIN_0.domain, DOMAIN_0, AUDIT_SOURCE), function (callback) { users.createOwner(USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE, function (error, result) { @@ -106,7 +108,7 @@ function setup(done) { }); }, (done) => mailboxdb.addMailbox(USER_0.username.toLowerCase(), DOMAIN_0.domain, USER_0.id, done), - (done) => mailboxdb.setAliasesForName(USER_0.username.toLowerCase(), DOMAIN_0.domain, [ USER_0_ALIAS.toLocaleLowerCase() ], done), + (done) => mailboxdb.setAliasesForName(USER_0.username.toLowerCase(), DOMAIN_0.domain, [ { name: USER_0_ALIAS.toLocaleLowerCase(), domain: DOMAIN_0.domain} ], done), appdb.update.bind(null, APP_0.id, { containerId: APP_0.containerId }), appdb.setAddonConfig.bind(null, APP_0.id, 'sendmail', [{ name: 'MAIL_SMTP_PASSWORD', value : 'sendmailpassword' }]), appdb.setAddonConfig.bind(null, APP_0.id, 'recvmail', [{ name: 'MAIL_IMAP_PASSWORD', value : 'recvmailpassword' }]),