diff --git a/migrations/20220817211634-mailboxes-add-quota.js b/migrations/20220817211634-mailboxes-add-quota.js new file mode 100644 index 000000000..127c201f0 --- /dev/null +++ b/migrations/20220817211634-mailboxes-add-quota.js @@ -0,0 +1,17 @@ +'use strict'; + +const async = require('async'); + +exports.up = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE mailboxes ADD COLUMN storageQuota BIGINT DEFAULT 0'), + db.runSql.bind(db, 'ALTER TABLE mailboxes ADD COLUMN messagesQuota BIGINT DEFAULT 0'), + ], callback); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE mailboxes DROP COLUMN storageQuota'), + db.runSql.bind(db, 'ALTER TABLE mailboxes DROP COLUMN messagesQuota') + ], callback); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index e015741e5..aa62973d9 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -217,6 +217,8 @@ CREATE TABLE IF NOT EXISTS mailboxes( domain VARCHAR(128), active BOOLEAN DEFAULT 1, enablePop3 BOOLEAN DEFAULT 0, + storageQuota BIGINT DEFAULT 0, + messagesQuota BIGINT DEFAULT 0, FOREIGN KEY(domain) REFERENCES mail(domain), FOREIGN KEY(aliasDomain) REFERENCES mail(domain), diff --git a/src/ldap.js b/src/ldap.js index 73838a407..442cdca03 100644 --- a/src/ldap.js +++ b/src/ldap.js @@ -286,7 +286,9 @@ async function mailboxSearch(req, res, next) { objectcategory: 'mailbox', cn: `${mailbox.name}@${mailbox.domain}`, uid: `${mailbox.name}@${mailbox.domain}`, - mail: `${mailbox.name}@${mailbox.domain}` + mail: `${mailbox.name}@${mailbox.domain}`, + storagequota: mailbox.storageQuota, + messagesquota: mailbox.messagesQuota, } }; @@ -323,7 +325,9 @@ async function mailboxSearch(req, res, next) { displayname: mailbox.ownerType === mail.OWNERTYPE_USER ? ownerObject.displayName : ownerObject.name, cn: `${mailbox.name}@${mailbox.domain}`, uid: `${mailbox.name}@${mailbox.domain}`, - mail: `${mailbox.name}@${mailbox.domain}` + mail: `${mailbox.name}@${mailbox.domain}`, + storagequota: mailbox.storageQuota, + messagesquota: mailbox.messagesQuota, } }; diff --git a/src/mail.js b/src/mail.js index 1bdb673cc..9f1279c12 100644 --- a/src/mail.js +++ b/src/mail.js @@ -112,7 +112,7 @@ const REMOVE_MAILBOX_CMD = path.join(__dirname, 'scripts/rmmailbox.sh'); const OWNERTYPES = [ exports.OWNERTYPE_USER, exports.OWNERTYPE_GROUP, exports.OWNERTYPE_APP ]; // if you add a field here, listMailboxes has to be updated -const MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain', 'active', 'enablePop3' ].join(','); +const MAILBOX_FIELDS = [ 'name', 'type', 'ownerId', 'ownerType', 'aliasName', 'aliasDomain', 'creationTime', 'membersJson', 'membersOnly', 'domain', 'active', 'enablePop3', 'storageQuota', 'messagesQuota' ].join(','); const MAILDB_FIELDS = [ 'domain', 'enabled', 'mailFromValidation', 'catchAllJson', 'relayJson', 'dkimKeyJson', 'dkimSelector', 'bannerJson' ].join(','); function postProcessMailbox(data) { @@ -1089,7 +1089,7 @@ async function listMailboxes(domain, search, page, perPage) { const escapedSearch = mysql.escape('%' + search + '%'); // this also quotes the string const searchQuery = search ? ` HAVING (name LIKE ${escapedSearch} OR aliasNames LIKE ${escapedSearch} OR aliasDomains LIKE ${escapedSearch})` : ''; // having instead of where because of aggregated columns use - const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3 ' + const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3, m1.storageQuota AS storageQuota, m1.messagesQuota AS messagesQuota ' + ` FROM (SELECT * FROM mailboxes WHERE type='${exports.TYPE_MAILBOX}') AS m1` + ` LEFT JOIN (SELECT * FROM mailboxes WHERE type='${exports.TYPE_ALIAS}') AS m2` + ' ON m1.name=m2.aliasName AND m1.domain=m2.aliasDomain AND m1.ownerId=m2.ownerId' @@ -1110,7 +1110,7 @@ async function listAllMailboxes(page, perPage) { assert.strictEqual(typeof page, 'number'); assert.strictEqual(typeof perPage, 'number'); - const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3 ' + const query = 'SELECT m1.name AS name, m1.domain AS domain, m1.ownerId AS ownerId, m1.ownerType as ownerType, m1.active as active, JSON_ARRAYAGG(m2.name) AS aliasNames, JSON_ARRAYAGG(m2.domain) AS aliasDomains, m1.enablePop3 AS enablePop3, m1.storageQuota AS storageQuota, m1.messagesQuota AS messagesQuota ' + ` FROM (SELECT * FROM mailboxes WHERE type='${exports.TYPE_MAILBOX}') AS m1` + ` LEFT JOIN (SELECT * FROM mailboxes WHERE type='${exports.TYPE_ALIAS}') AS m2` + ' ON m1.name=m2.aliasName AND m1.domain=m2.aliasDomain AND m1.ownerId=m2.ownerId' @@ -1164,10 +1164,12 @@ async function addMailbox(name, domain, data, auditSource) { assert.strictEqual(typeof data, 'object'); assert.strictEqual(typeof auditSource, 'object'); - const { ownerId, ownerType, active } = data; + const { ownerId, ownerType, active, storageQuota, messagesQuota } = data; assert.strictEqual(typeof ownerId, 'string'); assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof active, 'boolean'); + assert(Number.isInteger(storageQuota) && storageQuota >= 0); + assert(Number.isInteger(messagesQuota) && messagesQuota >= 0); name = name.toLowerCase(); @@ -1176,12 +1178,13 @@ async function addMailbox(name, domain, data, auditSource) { if (!OWNERTYPES.includes(ownerType)) throw new BoxError(BoxError.BAD_FIELD, 'bad owner type'); - [error] = await safe(database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType, active) VALUES (?, ?, ?, ?, ?, ?)', [ name, exports.TYPE_MAILBOX, domain, ownerId, ownerType, active ])); + [error] = await safe(database.query('INSERT INTO mailboxes (name, type, domain, ownerId, ownerType, active, storageQuota, messagesQuota) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ name, exports.TYPE_MAILBOX, domain, ownerId, ownerType, active, storageQuota, messagesQuota ])); if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'mailbox already exists'); if (error && error.code === 'ER_NO_REFERENCED_ROW_2' && error.sqlMessage.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.NOT_FOUND, `no such domain '${domain}'`); if (error) throw error; - await eventlog.add(eventlog.ACTION_MAIL_MAILBOX_ADD, auditSource, { name, domain, ownerId, ownerType, active }); + await eventlog.add(eventlog.ACTION_MAIL_MAILBOX_ADD, auditSource, { name, domain, ownerId, ownerType, active, storageQuota, messageQuota: messagesQuota }); } async function updateMailbox(name, domain, data, auditSource) { @@ -1190,11 +1193,13 @@ async function updateMailbox(name, domain, data, auditSource) { assert.strictEqual(typeof data, 'object'); assert.strictEqual(typeof auditSource, 'object'); - const { ownerId, ownerType, active, enablePop3 } = data; + const { ownerId, ownerType, active, enablePop3, storageQuota, messagesQuota } = data; assert.strictEqual(typeof ownerId, 'string'); assert.strictEqual(typeof ownerType, 'string'); assert.strictEqual(typeof active, 'boolean'); assert.strictEqual(typeof enablePop3, 'boolean'); + assert(Number.isInteger(storageQuota) && storageQuota >= 0); + assert(Number.isInteger(messagesQuota) && messagesQuota >= 0); name = name.toLowerCase(); @@ -1203,10 +1208,11 @@ async function updateMailbox(name, domain, data, auditSource) { const mailbox = await getMailbox(name, domain); if (!mailbox) throw new BoxError(BoxError.NOT_FOUND, 'No such mailbox'); - const result = await database.query('UPDATE mailboxes SET ownerId = ?, ownerType = ?, active = ?, enablePop3 = ? WHERE name = ? AND domain = ?', [ ownerId, ownerType, active, enablePop3, name, domain ]); + const result = await database.query('UPDATE mailboxes SET ownerId = ?, ownerType = ?, active = ?, enablePop3 = ?, storageQuota = ?, messagesQuota = ? WHERE name = ? AND domain = ?', + [ ownerId, ownerType, active, enablePop3, storageQuota, messagesQuota, name, domain ]); if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Mailbox not found'); - await eventlog.add(eventlog.ACTION_MAIL_MAILBOX_UPDATE, auditSource, { name, domain, oldUserId: mailbox.userId, ownerId, ownerType, active }); + await eventlog.add(eventlog.ACTION_MAIL_MAILBOX_UPDATE, auditSource, { name, domain, oldUserId: mailbox.userId, ownerId, ownerType, active, storageQuota, messagesQuota }); } async function removeSolrIndex(mailbox) { diff --git a/src/routes/mail.js b/src/routes/mail.js index a360cc3b6..8f77c3af9 100644 --- a/src/routes/mail.js +++ b/src/routes/mail.js @@ -177,6 +177,11 @@ async function addMailbox(req, res, next) { if (typeof req.body.ownerType !== 'string') return next(new HttpError(400, 'ownerType must be a string')); if (typeof req.body.active !== 'boolean') return next(new HttpError(400, 'active must be a boolean')); + if (!Number.isInteger(req.body.storageQuota)) return next(new HttpError(400, 'storageQuota must be an integer')); + if (req.body.storageQuota < 0) return next(new HttpError(400, 'storageQuota must be a postive integer or zero')); + if (!Number.isInteger(req.body.messagesQuota)) return next(new HttpError(400, 'messagesQuota must be an integer')); + if (req.body.messagesQuota < 0) return next(new HttpError(400, 'messagesQuota must be a positive integer or zero')); + const [error] = await safe(mail.addMailbox(req.body.name, req.params.domain, req.body, AuditSource.fromRequest(req))); if (error) return next(BoxError.toHttpError(error)); @@ -192,6 +197,11 @@ async function updateMailbox(req, res, next) { if (typeof req.body.active !== 'boolean') return next(new HttpError(400, 'active must be a boolean')); if (typeof req.body.enablePop3 !== 'boolean') return next(new HttpError(400, 'enablePop3 must be a boolean')); + if (!Number.isInteger(req.body.storageQuota)) return next(new HttpError(400, 'storageQuota must be an integer')); + if (req.body.storageQuota < 0) return next(new HttpError(400, 'storageQuota must be a postive integer or zero')); + if (!Number.isInteger(req.body.messagesQuota)) return next(new HttpError(400, 'messagesQuota must be an integer')); + if (req.body.messagesQuota < 0) return next(new HttpError(400, 'messagesQuota must be a positive integer or zero')); + const [error] = await safe(mail.updateMailbox(req.params.name, req.params.domain, req.body, AuditSource.fromRequest(req))); if (error) return next(BoxError.toHttpError(error)); diff --git a/src/routes/test/mail-test.js b/src/routes/test/mail-test.js index bb2aea95e..4703c8dd7 100644 --- a/src/routes/test/mail-test.js +++ b/src/routes/test/mail-test.js @@ -422,7 +422,7 @@ describe('Mail API', function () { it('add succeeds', async function () { const response = await superagent.post(`${serverUrl}/api/v1/mail/${dashboardDomain}/mailboxes`) - .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true }) + .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true, storageQuota: 10, messagesQuota: 20 }) .query({ access_token: owner.token }); expect(response.statusCode).to.equal(201); @@ -430,7 +430,7 @@ describe('Mail API', function () { it('cannot add again', async function () { const response = await superagent.post(`${serverUrl}/api/v1/mail/${dashboardDomain}/mailboxes`) - .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true }) + .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true, storageQuota: 10, messagesQuota: 20 }) .query({ access_token: owner.token }) .ok(() => true); @@ -457,6 +457,8 @@ describe('Mail API', function () { expect(response.body.mailbox.aliasName).to.equal(null); expect(response.body.mailbox.aliasDomain).to.equal(null); expect(response.body.mailbox.domain).to.equal(dashboardDomain); + expect(response.body.mailbox.storageQuota).to.equal(10); + expect(response.body.mailbox.messagesQuota).to.equal(20); }); it('listing succeeds', async function () { @@ -471,6 +473,8 @@ describe('Mail API', function () { expect(response.body.mailboxes[0].ownerType).to.equal('user'); expect(response.body.mailboxes[0].aliases).to.eql([]); expect(response.body.mailboxes[0].domain).to.equal(dashboardDomain); + expect(response.body.mailboxes[0].storageQuota).to.equal(10); + expect(response.body.mailboxes[0].messagesQuota).to.equal(20); }); it('disable fails even if not exist', async function () { @@ -505,7 +509,7 @@ describe('Mail API', function () { it('add the mailbox', async function () { const response = await superagent.post(`${serverUrl}/api/v1/mail/${dashboardDomain}/mailboxes`) - .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true }) + .send({ name: MAILBOX_NAME, ownerId: owner.id, ownerType: 'user', active: true, storageQuota: 10, messagesQuota: 20 }) .query({ access_token: owner.token }); expect(response.statusCode).to.equal(201); diff --git a/src/test/mail-test.js b/src/test/mail-test.js index 4c1db04d9..74728dbb6 100644 --- a/src/test/mail-test.js +++ b/src/test/mail-test.js @@ -114,16 +114,16 @@ describe('Mail', function () { describe('mailboxes', function () { it('add user mailbox succeeds', async function () { - await mail.addMailbox('girish', domain.domain, { ownerId: 'uid-0', ownerType: mail.OWNERTYPE_USER, active: true }, auditSource); + await mail.addMailbox('girish', domain.domain, { ownerId: 'uid-0', ownerType: mail.OWNERTYPE_USER, active: true, storageQuota: 0, messagesQuota: 0 }, auditSource); }); it('cannot add dup entry', async function () { - const [error] = await safe(mail.addMailbox('girish', domain.domain, { ownerId: 'uid-1', ownerType: mail.OWNERTYPE_GROUP, active: true }, auditSource)); + const [error] = await safe(mail.addMailbox('girish', domain.domain, { ownerId: 'uid-1', ownerType: mail.OWNERTYPE_GROUP, active: true, storageQuota: 0, messagesQuota: 0 }, auditSource)); expect(error.reason).to.be(BoxError.ALREADY_EXISTS); }); it('add app mailbox succeeds', async function () { - await mail.addMailbox('support', domain.domain, { ownerId: 'osticket', ownerType: 'user', active: true}, auditSource); + await mail.addMailbox('support', domain.domain, { ownerId: 'osticket', ownerType: 'user', active: true, storageQuota: 10, messagesQuota: 20}, auditSource); }); it('get succeeds', async function () { @@ -132,6 +132,8 @@ describe('Mail', function () { expect(mailbox.ownerId).to.equal('osticket'); expect(mailbox.domain).to.equal(domain.domain); expect(mailbox.creationTime).to.be.a(Date); + expect(mailbox.storageQuota).to.be(10); + expect(mailbox.messagesQuota).to.be(20); }); it('get non-existent mailbox', async function () { @@ -139,6 +141,14 @@ describe('Mail', function () { expect(mailbox).to.be(null); }); + it('update app mailbox succeeds', async function () { + await mail.updateMailbox('support', domain.domain, { ownerId: 'osticket', ownerType: 'user', active: true, storageQuota: 20, messagesQuota: 30}, auditSource); + + const mailbox = await mail.getMailbox('support', domain.domain); + expect(mailbox.storageQuota).to.be(20); + expect(mailbox.messagesQuota).to.be(30); + }); + it('list mailboxes succeeds', async function () { const mailboxes = await mail.listMailboxes(domain.domain, null /* search */, 1, 10); expect(mailboxes.length).to.be(2);