wildduck/lib/api/addresses.js
2021-05-21 20:14:43 +03:00

2320 lines
78 KiB
JavaScript

'use strict';
const config = require('wild-config');
const Joi = require('joi');
const MongoPaging = require('mongo-cursor-pagination');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const consts = require('../consts');
const roles = require('../roles');
const libmime = require('libmime');
const { nextPageCursorSchema, previousPageCursorSchema, pageNrSchema, sessSchema, sessIPSchema, booleanSchema, metaDataSchema } = require('../schemas');
const log = require('npmlog');
const isemail = require('isemail');
const {
publish,
ADDRESS_USER_CREATED,
ADDRESS_USER_DELETED,
ADDRESS_FORWARDED_CREATED,
ADDRESS_FORWARDED_DELETED,
ADDRESS_DOMAIN_RENAMED
} = require('../events');
module.exports = (db, server, userHandler) => {
server.get(
{ name: 'addresses', path: '/addresses' },
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
query: Joi.string().trim().empty('').max(255),
tags: Joi.string().trim().empty('').max(1024),
requiredTags: Joi.string().trim().empty('').max(1024),
metaData: booleanSchema,
internalData: booleanSchema,
limit: Joi.number().default(20).min(1).max(250),
next: nextPageCursorSchema,
previous: previousPageCursorSchema,
page: pageNrSchema,
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true,
allowUnknown: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
let permission;
let ownOnly = false;
permission = roles.can(req.role).readAny('addresslisting');
if (!permission.granted && req.user && ObjectID.isValid(req.user)) {
permission = roles.can(req.role).readOwn('addresslisting');
if (permission.granted) {
ownOnly = true;
}
}
// permissions check
req.validate(permission);
let query = result.value.query;
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrevious = result.value.previous;
let filter =
(query && {
address: {
// cannot use dotless version as this would break domain search
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
}) ||
{};
let tagSeen = new Set();
let requiredTags = (result.value.requiredTags || '')
.split(',')
.map(tag => tag.toLowerCase().trim())
.filter(tag => {
if (tag && !tagSeen.has(tag)) {
tagSeen.add(tag);
return true;
}
return false;
});
let tags = (result.value.tags || '')
.split(',')
.map(tag => tag.toLowerCase().trim())
.filter(tag => {
if (tag && !tagSeen.has(tag)) {
tagSeen.add(tag);
return true;
}
return false;
});
let tagsview = {};
if (requiredTags.length) {
tagsview.$all = requiredTags;
}
if (tags.length) {
tagsview.$in = tags;
}
if (requiredTags.length || tags.length) {
filter.tagsview = tagsview;
}
if (ownOnly) {
filter.user = new ObjectID(req.user);
}
let total = await db.users.collection('addresses').countDocuments(filter);
let opts = {
limit,
query: filter,
fields: {
addrview: true,
// FIXME: MongoPaging inserts fields value as second argument to col.find()
projection: {
_id: true,
address: true,
addrview: true,
name: true,
user: true,
tags: true,
targets: true,
forwardedDisabled: true
}
},
paginatedField: 'addrview',
sortAscending: true
};
if (result.value.metaData) {
opts.fields.projection.metaData = true;
}
if (result.value.internalData) {
opts.fields.projection.internalData = true;
}
if (pageNext) {
opts.next = pageNext;
} else if ((!page || page > 1) && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.users.collection('addresses'), opts);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!listing.hasPrevious) {
page = 1;
}
let response = {
success: true,
query,
total,
page,
previousCursor: listing.hasPrevious ? listing.previous : false,
nextCursor: listing.hasNext ? listing.next : false,
results: (listing.results || []).map(addressData => {
let values = {
id: addressData._id.toString(),
name: addressData.name || false,
address: addressData.address,
user: addressData.user && addressData.user.toString(),
forwarded: !!addressData.targets,
forwardedDisabled: !!(addressData.targets && addressData.forwardedDisabled),
targets: addressData.targets && addressData.targets.map(t => t.value),
tags: addressData.tags || []
};
if (addressData.metaData) {
values.metaData = tools.formatMetaData(addressData.metaData);
}
if (addressData.internalData) {
values.internalData = tools.formatMetaData(addressData.internalData);
}
return permission.filter(values);
})
};
res.json(response);
return next();
})
);
server.post(
'/users/:user/addresses',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
address: [Joi.string().email({ tlds: false }).required(), Joi.string().regex(/^\w+@\*$/, 'special address')],
name: Joi.string().empty('').trim().max(128),
main: booleanSchema,
allowWildcard: booleanSchema,
tags: Joi.array().items(Joi.string().trim().max(128)),
metaData: metaDataSchema.label('metaData'),
internalData: metaDataSchema.label('internalData'),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).createOwn('addresses'));
} else {
req.validate(roles.can(req.role).createAny('addresses'));
}
let main = result.value.main;
let name = result.value.name;
let address = tools.normalizeAddress(result.value.address);
if (address.indexOf('+') >= 0) {
res.json({
error: 'Address can not contain +'
});
return next();
}
let wcpos = address.indexOf('*');
if (wcpos >= 0) {
if (!result.value.allowWildcard) {
res.json({
error: 'Address can not contain *'
});
return next();
}
// wildcard in the beginning of username
if (address.charAt(0) === '*') {
let partial = address.substr(1);
try {
// only one wildcard allowed
if (partial.indexOf('*') >= 0) {
throw new Error('Invalid wildcard address');
}
// for validation we need a correct email
if (partial.charAt(0) === '@') {
partial = 'test' + partial;
}
// check if wildcard username is not too long
if (partial.substr(0, partial.indexOf('@')).length > consts.MAX_ALLOWED_WILDCARD_LENGTH) {
throw new Error('Invalid wildcard address');
}
// result neewds to be a valid email
if (!isemail.validate(partial)) {
throw new Error('Invalid wildcard address');
}
} catch (err) {
res.json({
error: 'Invalid wildcard address, use "*@domain" or "user@*"'
});
return next();
}
}
if (address.charAt(address.length - 1) === '*') {
let partial = address.substr(0, address.length - 1);
try {
// only one wildcard allowed
if (partial.indexOf('*') >= 0) {
throw new Error('Invalid wildcard address');
}
// for validation we need a correct email
partial += 'example.com';
if (!isemail.validate(partial)) {
throw new Error('Invalid wildcard address');
}
} catch (err) {
res.json({
error: 'Invalid wildcard address, use "*@domain" or "user@*"'
});
return next();
}
}
if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) {
res.json({
error: 'Invalid wildcard address, use "*@domain" or "user@*"'
});
return next();
}
if (main) {
res.json({
error: 'Main address can not contain *'
});
return next();
}
}
if (result.value.tags) {
let tagSeen = new Set();
let tags = result.value.tags
.map(tag => tag.trim())
.filter(tag => {
if (tag && !tagSeen.has(tag.toLowerCase())) {
tagSeen.add(tag.toLowerCase());
return true;
}
return false;
})
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
result.value.tags = tags;
result.value.tagsview = tags.map(tag => tag.toLowerCase());
}
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
addrview: tools.uview(address)
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (addressData) {
res.status(400);
res.json({
error: 'This email address already exists',
code: 'AddressExistsError'
});
return next();
}
addressData = {
user,
name,
address,
addrview: tools.uview(address),
created: new Date()
};
if (result.value.tags) {
addressData.tags = result.value.tags;
addressData.tagsview = result.value.tags;
}
if (result.value.metaData) {
addressData.metaData = result.value.metaData;
}
if (result.value.internalData) {
addressData.internalData = result.value.internalData;
}
let r;
// insert alias address to email address registry
try {
r = await db.users.collection('addresses').insertOne(addressData);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
let insertId = r.insertedId;
if (!userData.address || main) {
// register this address as the default address for that user
try {
await db.users.collection('users').updateOne(
{
_id: user
},
{
$set: {
address
}
}
);
} catch (err) {
// ignore
}
}
await publish(db.redis, {
ev: ADDRESS_USER_CREATED,
user,
address: insertId,
value: addressData.address
});
res.json({
success: !!insertId,
id: insertId
});
return next();
})
);
server.get(
'/users/:user/addresses',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
metaData: booleanSchema,
internalData: booleanSchema,
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
let permission;
if (req.user && req.user === result.value.user) {
permission = roles.can(req.role).readOwn('addresses');
} else {
permission = roles.can(req.role).readAny('addresses');
}
// permissions check
req.validate(permission);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
name: true,
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addresses;
try {
addresses = await db.users
.collection('addresses')
.find({
user
})
.sort({
addrview: 1
})
.toArray();
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addresses) {
addresses = [];
}
res.json({
success: true,
results: addresses.map(addressData => {
let values = {
id: addressData._id.toString(),
name: addressData.name || false,
address: addressData.address,
main: addressData.address === userData.address,
tags: addressData.tags || [],
created: addressData.created
};
if (result.value.metaData && addressData.metaData) {
values.metaData = tools.formatMetaData(addressData.metaData);
}
if (result.value.internalData && addressData.internalData) {
values.internalData = tools.formatMetaData(addressData.internalData);
}
return permission.filter(values);
})
});
return next();
})
);
server.get(
'/users/:user/addresses/:address',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
address: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
let permission;
if (req.user && req.user === result.value.user) {
permission = roles.can(req.role).readOwn('addresses');
} else {
permission = roles.can(req.role).readAny('addresses');
}
req.validate(permission);
let address = new ObjectID(result.value.address);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
name: true,
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: address,
user
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData) {
res.status(404);
res.json({
error: 'Invalid or unknown address',
code: 'AddressNotFound'
});
return next();
}
let value = {
success: true,
id: addressData._id.toString(),
name: addressData.name || false,
address: addressData.address,
main: addressData.address === userData.address,
tags: addressData.tags || [],
created: addressData.created
};
if (addressData.metaData) {
value.metaData = tools.formatMetaData(addressData.metaData);
}
if (addressData.internalData) {
value.internalData = tools.formatMetaData(addressData.internalData);
}
res.json(permission.filter(value));
return next();
})
);
server.put(
'/users/:user/addresses/:id',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
id: Joi.string().hex().lowercase().length(24).required(),
name: Joi.string().empty('').trim().max(128),
address: Joi.string().email({ tlds: false }),
main: booleanSchema,
tags: Joi.array().items(Joi.string().trim().max(128)),
metaData: metaDataSchema.label('metaData'),
internalData: metaDataSchema.label('internalData'),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).updateOwn('addresses'));
} else {
req.validate(roles.can(req.role).updateAny('addresses'));
}
let id = new ObjectID(result.value.id);
let main = result.value.main;
if (main === false) {
res.json({
error: 'Cannot unset main status'
});
return next();
}
let updates = {};
if (result.value.address) {
let address = tools.normalizeAddress(result.value.address);
let addrview = tools.uview(address);
updates.address = address;
updates.addrview = addrview;
}
if (result.value.name) {
updates.name = result.value.name;
}
if (result.value.tags) {
let tagSeen = new Set();
let tags = result.value.tags
.map(tag => tag.trim())
.filter(tag => {
if (tag && !tagSeen.has(tag.toLowerCase())) {
tagSeen.add(tag.toLowerCase());
return true;
}
return false;
})
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
updates.tags = tags;
updates.tagsview = tags.map(tag => tag.toLowerCase());
}
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: id
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData || !addressData.user || addressData.user.toString() !== user.toString()) {
res.status(404);
res.json({
error: 'Invalid or unknown email address identifier',
code: 'AddressNotFound'
});
return next();
}
if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) {
res.status(400);
res.json({
error: 'Can not change special address',
code: 'ChangeNotAllowed'
});
return next();
}
if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) {
res.status(400);
res.json({
error: 'Can not change special address',
code: 'ChangeNotAllowed'
});
return next();
}
if ((result.value.address || addressData.address).indexOf('*') >= 0 && main) {
res.status(400);
res.json({
error: 'Can not set wildcard address as default',
code: 'WildcardNotPermitted'
});
return next();
}
if (result.value.address && addressData.address === userData.address && result.value.address !== addressData.address) {
// main address was changed, update user data as well
main = true;
addressData.address = result.value.address;
}
for (let key of ['metaData', 'internalData']) {
if (result.value[key]) {
updates[key] = result.value[key];
}
}
if (Object.keys(updates).length) {
try {
await db.users.collection('addresses').updateOne(
{
_id: addressData._id
},
{
$set: updates
}
);
} catch (err) {
if (err.code === 11000) {
res.status(400);
res.json({
error: 'Address already exists',
code: 'AddressExistsError'
});
} else {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
}
return next();
}
}
if (!main) {
// nothing to do anymore
res.json({
success: true
});
return next();
}
let r;
try {
r = await db.users.collection('users').updateOne(
{
_id: user
},
{
$set: {
address: addressData.address
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
res.json({
success: !!r.matchedCount
});
return next();
})
);
server.del(
'/users/:user/addresses/:address',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
address: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).deleteOwn('addresses'));
} else {
req.validate(roles.can(req.role).deleteAny('addresses'));
}
let address = new ObjectID(result.value.address);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: address
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData || addressData.user.toString() !== user.toString()) {
res.status(404);
res.json({
error: 'Invalid or unknown email address identifier',
code: 'AddressNotFound'
});
return next();
}
if (addressData.address === userData.address) {
res.json({
error: 'Trying to delete main address. Set a new main address first'
});
return next();
}
// delete address from email address registry
let r;
try {
r = await db.users.collection('addresses').deleteOne({
_id: address
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (r.deletedCount) {
await publish(db.redis, {
ev: ADDRESS_USER_DELETED,
user,
address,
value: addressData.address
});
}
res.json({
success: !!r.deletedCount
});
return next();
})
);
server.get(
'/users/:user/addressregister',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
query: Joi.string().trim().empty('').max(255).required(),
limit: Joi.number().default(20).min(1).max(250),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
let user = new ObjectID(result.value.user);
// permissions check
let permission;
if (req.user && req.user === result.value.user) {
permission = roles.can(req.role).readOwn('addresses');
} else {
permission = roles.can(req.role).readAny('addresses');
}
// permissions check
req.validate(permission);
let query = result.value.query;
let limit = result.value.limit;
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
_id: true,
name: true,
address: true
}
}
);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.status(404);
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let addresses;
try {
addresses = await db.database
.collection('addressregister')
.find(
{
user,
$or: [
{
address: {
// cannot use dotless version as this would break domain search
$regex: '^' + query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
},
{
name: {
// cannot use dotless version as this would break domain search
$regex: '^' + query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: 'i'
}
}
]
},
{
sort: { updated: -1 },
projection: {
name: true,
address: true
},
limit
}
)
.toArray();
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addresses) {
addresses = [];
}
res.json({
success: true,
results: addresses.map(addressData => {
let name = addressData.name || false;
try {
// try to decode
if (name) {
name = libmime.decodeWords(name);
}
} catch (E) {
// ignore
}
return {
id: addressData._id.toString(),
name: addressData.name || false,
address: addressData.address
};
})
});
return next();
})
);
server.post(
'/addresses/forwarded',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
address: Joi.alternatives()
.try(Joi.string().email({ tlds: false }).required(), Joi.string().regex(/^\w+@\*$/, 'special address'))
.required(),
name: Joi.string().empty('').trim().max(128),
targets: Joi.array().items(
Joi.string().email({ tlds: false }),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
forwards: Joi.number().min(0).default(0),
allowWildcard: booleanSchema,
autoreply: Joi.object().keys({
status: booleanSchema.default(true),
start: Joi.date().empty('').allow(false),
end: Joi.date().empty('').allow(false),
name: Joi.string().empty('').trim().max(128),
subject: Joi.string().empty('').trim().max(128),
text: Joi.string()
.empty('')
.trim()
.max(128 * 1024),
html: Joi.string()
.empty('')
.trim()
.max(128 * 1024)
}),
tags: Joi.array().items(Joi.string().trim().max(128)),
metaData: metaDataSchema.label('metaData'),
internalData: metaDataSchema.label('internalData'),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).createAny('addresses'));
let address = tools.normalizeAddress(result.value.address);
let addrview = tools.uview(address);
let name = result.value.name;
let targets = result.value.targets || [];
let forwards = result.value.forwards;
if (result.value.autoreply) {
if (!result.value.autoreply.name && 'name' in req.params.autoreply) {
result.value.autoreply.name = '';
}
if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) {
result.value.autoreply.subject = '';
}
if (!result.value.autoreply.text && 'text' in req.params.autoreply) {
result.value.autoreply.text = '';
if (!result.value.autoreply.html) {
// make sure we also update html part
result.value.autoreply.html = '';
}
}
if (!result.value.autoreply.html && 'html' in req.params.autoreply) {
result.value.autoreply.html = '';
if (!result.value.autoreply.text) {
// make sure we also update plaintext part
result.value.autoreply.text = '';
}
}
} else {
result.value.autoreply = {
status: false
};
}
if (result.value.tags) {
let tagSeen = new Set();
let tags = result.value.tags
.map(tag => tag.trim())
.filter(tag => {
if (tag && !tagSeen.has(tag.toLowerCase())) {
tagSeen.add(tag.toLowerCase());
return true;
}
return false;
})
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
result.value.tags = tags;
result.value.tagsview = tags.map(tag => tag.toLowerCase());
}
// needed to resolve users for addresses
let addrlist = [];
let cachedAddrviews = new WeakMap();
for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
let addr = tools.normalizeAddress(target);
let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@'));
if (addrv === addrview) {
res.status(400);
res.json({
error: 'Can not forward to self "' + target + '"',
code: 'InputValidationError'
});
return next();
}
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
cachedAddrviews.set(targets[i], addrv);
addrlist.push(addrv);
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.status(400);
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
}
if (address.indexOf('+') >= 0) {
res.json({
error: 'Address can not contain +'
});
return next();
}
let wcpos = address.indexOf('*');
if (wcpos >= 0) {
if (!result.value.allowWildcard) {
res.json({
error: 'Address can not contain *'
});
return next();
}
if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) {
res.json({
error: 'Invalid wildcard address, use "*@domain" or "user@*"'
});
return next();
}
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
addrview
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (addressData) {
res.status(400);
res.json({
error: 'This email address already exists',
code: 'AddressExistsError'
});
return next();
}
if (addrlist.length) {
let addressList;
try {
addressList = await db.users
.collection('addresses')
.find({
addrview: { $in: addrlist }
})
.toArray();
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user]));
targets.forEach(target => {
let addrv = cachedAddrviews.get(target);
if (addrv && map.has(addrv)) {
target.user = map.get(addrv);
}
});
}
// insert alias address to email address registry
addressData = {
name,
address,
addrview: tools.uview(address),
targets,
forwards,
autoreply: result.value.autoreply,
created: new Date()
};
if (result.value.tags) {
addressData.tags = result.value.tags;
addressData.tagsview = result.value.tags;
}
if (result.value.metaData) {
addressData.metaData = result.value.metaData;
}
if (result.value.internalData) {
addressData.internalData = result.value.internalData;
}
let r;
try {
r = await db.users.collection('addresses').insertOne(addressData);
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
let insertId = r.insertedId;
await publish(db.redis, {
ev: ADDRESS_FORWARDED_CREATED,
address: insertId,
value: addressData.address
});
res.json({
success: !!insertId,
id: insertId
});
return next();
})
);
server.put(
'/addresses/forwarded/:id',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
id: Joi.string().hex().lowercase().length(24).required(),
address: Joi.string().email({ tlds: false }),
name: Joi.string().empty('').trim().max(128),
targets: Joi.array().items(
Joi.string().email({ tlds: false }),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
forwards: Joi.number().min(0),
autoreply: Joi.object().keys({
status: booleanSchema,
start: Joi.date().empty('').allow(false),
end: Joi.date().empty('').allow(false),
name: Joi.string().empty('').trim().max(128),
subject: Joi.string().empty('').trim().max(128),
text: Joi.string()
.empty('')
.trim()
.max(128 * 1024),
html: Joi.string()
.empty('')
.trim()
.max(128 * 1024)
}),
tags: Joi.array().items(Joi.string().trim().max(128)),
metaData: metaDataSchema.label('metaData'),
internalData: metaDataSchema.label('internalData'),
forwardedDisabled: booleanSchema,
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).updateAny('addresses'));
let id = new ObjectID(result.value.id);
let updates = {};
if (result.value.address) {
let address = tools.normalizeAddress(result.value.address);
let addrview = tools.uview(address);
updates.address = address;
updates.addrview = addrview;
}
if (result.value.forwards) {
updates.forwards = result.value.forwards;
}
if (result.value.name) {
updates.name = result.value.name;
}
if (result.value.forwardedDisabled !== undefined) {
updates.forwardedDisabled = result.value.forwardedDisabled;
}
if (result.value.autoreply) {
if (!result.value.autoreply.name && 'name' in req.params.autoreply) {
result.value.autoreply.name = '';
}
if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) {
result.value.autoreply.subject = '';
}
if (!result.value.autoreply.text && 'text' in req.params.autoreply) {
result.value.autoreply.text = '';
if (!result.value.autoreply.html) {
// make sure we also update html part
result.value.autoreply.html = '';
}
}
if (!result.value.autoreply.html && 'html' in req.params.autoreply) {
result.value.autoreply.html = '';
if (!result.value.autoreply.text) {
// make sure we also update plaintext part
result.value.autoreply.text = '';
}
}
Object.keys(result.value.autoreply).forEach(key => {
updates['autoreply.' + key] = result.value.autoreply[key];
});
}
if (result.value.tags) {
let tagSeen = new Set();
let tags = result.value.tags
.map(tag => tag.trim())
.filter(tag => {
if (tag && !tagSeen.has(tag.toLowerCase())) {
tagSeen.add(tag.toLowerCase());
return true;
}
return false;
})
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
updates.tags = tags;
updates.tagsview = tags.map(tag => tag.toLowerCase());
}
if (result.value.metaData) {
updates.metaData = result.value.metaData;
}
if (result.value.internalData) {
updates.internalData = result.value.internalData;
}
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: id
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData || !addressData.targets || addressData.user) {
res.status(404);
res.json({
error: 'Invalid or unknown email address identifier',
code: 'AddressNotFound'
});
return next();
}
if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) {
res.status(400);
res.json({
error: 'Can not change special address',
code: 'ChangeNotAllowed'
});
return next();
}
if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) {
res.status(400);
res.json({
error: 'Can not change special address',
code: 'ChangeNotAllowed'
});
return next();
}
let targets = result.value.targets;
let addrlist = [];
let cachedAddrviews = new WeakMap();
if (targets) {
// needed to resolve users for addresses
for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
let addr = tools.normalizeAddress(target);
let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@'));
if (addrv === addressData.addrview) {
res.status(400);
res.json({
error: 'Can not forward to self "' + target + '"',
code: 'InputValidationError'
});
return next();
}
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
cachedAddrviews.set(targets[i], addrv);
addrlist.push(addrv);
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.status(400);
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
}
updates.targets = targets;
}
if (targets && addrlist.length) {
let addressList;
try {
addressList = await db.users
.collection('addresses')
.find({
addrview: { $in: addrlist }
})
.toArray();
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user]));
targets.forEach(target => {
let addrv = cachedAddrviews.get(target);
if (addrv && map.has(addrv)) {
target.user = map.get(addrv);
}
});
}
// insert alias address to email address registry
let r;
try {
r = await db.users.collection('addresses').updateOne(
{
_id: addressData._id
},
{
$set: updates
}
);
} catch (err) {
if (err.code === 11000) {
res.status(400);
res.json({
error: 'Address already exists',
code: 'AddressExistsError'
});
} else {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
}
return next();
}
res.json({
success: !!r.matchedCount
});
return next();
})
);
server.del(
'/addresses/forwarded/:address',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
address: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).deleteAny('addresses'));
let address = new ObjectID(result.value.address);
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: address
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData || !addressData.targets || addressData.user) {
res.status(404);
res.json({
error: 'Invalid or unknown email address identifier',
code: 'AddressNotFound'
});
return next();
}
// delete address from email address registry
let r;
try {
r = await db.users.collection('addresses').deleteOne({
_id: address
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (r.deletedCount) {
await publish(db.redis, {
ev: ADDRESS_FORWARDED_DELETED,
address,
value: addressData.address
});
}
res.json({
success: !!r.deletedCount
});
return next();
})
);
server.get(
'/addresses/forwarded/:address',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
address: Joi.string().hex().lowercase().length(24).required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
const permission = roles.can(req.role).readAny('addresses');
req.validate(permission);
let address = new ObjectID(result.value.address);
let addressData;
try {
addressData = await db.users.collection('addresses').findOne({
_id: address
});
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData || !addressData.targets || addressData.user) {
res.status(404);
res.json({
error: 'Invalid or unknown address',
code: 'AddressNotFound'
});
return next();
}
let response;
try {
response = await db.redis
.multi()
// sending counters are stored in Redis
.get('wdf:' + addressData._id.toString())
.ttl('wdf:' + addressData._id.toString())
.exec();
} catch (err) {
// ignore
}
let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS;
let forwardsSent = Number(response && response[0] && response[0][1]) || 0;
let forwardsTtl = Number(response && response[1] && response[1][1]) || 0;
const values = {
success: true,
id: addressData._id.toString(),
name: addressData.name || false,
address: addressData.address,
targets: addressData.targets && addressData.targets.map(t => t.value),
limits: {
forwards: {
allowed: forwards,
used: forwardsSent,
ttl: forwardsTtl >= 0 ? forwardsTtl : false
}
},
autoreply: addressData.autoreply || { status: false },
tags: addressData.tags || [],
forwardedDisabled: addressData.targets && addressData.forwardedDisabled,
created: addressData.created
};
if (addressData.metaData) {
values.metaData = tools.formatMetaData(addressData.metaData);
}
if (addressData.internalData) {
values.internalData = tools.formatMetaData(addressData.internalData);
}
res.json(permission.filter(values));
return next();
})
);
server.get(
'/addresses/resolve/:address',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
address: [Joi.string().hex().lowercase().length(24).required(), Joi.string().email({ tlds: false })],
allowWildcard: booleanSchema,
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
const permission = roles.can(req.role).readAny('addresses');
req.validate(permission);
let addressData;
try {
if (result.value.address.indexOf('@') >= 0) {
addressData = await userHandler.asyncResolveAddress(result.value.address, {
wildcard: result.value.allowWildcard,
projection: false
});
} else {
addressData = await db.users.collection('addresses').findOne({
_id: new ObjectID(result.value.address)
});
}
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!addressData) {
res.status(404);
res.json({
error: 'Invalid or unknown address',
code: 'AddressNotFound'
});
return next();
}
if (addressData.user) {
const values = {
success: true,
id: addressData._id.toString(),
address: addressData.address,
user: addressData.user.toString(),
tags: addressData.tags || [],
created: addressData.created
};
if (addressData.metaData) {
values.metaData = tools.formatMetaData(addressData.metaData);
}
if (addressData.internalData) {
values.internalData = tools.formatMetaData(addressData.internalData);
}
res.json(permission.filter(values));
return next();
}
let response;
try {
response = await db.redis
.multi()
// sending counters are stored in Redis
.get('wdf:' + addressData._id.toString())
.ttl('wdf:' + addressData._id.toString())
.exec();
} catch (err) {
// ignore
}
let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS;
let forwardsSent = Number(response && response[0] && response[0][1]) || 0;
let forwardsTtl = Number(response && response[1] && response[1][1]) || 0;
const values = {
success: true,
id: addressData._id.toString(),
name: addressData.name || '',
address: addressData.address,
targets: addressData.targets && addressData.targets.map(t => t.value),
limits: {
forwards: {
allowed: forwards,
used: forwardsSent,
ttl: forwardsTtl >= 0 ? forwardsTtl : false
}
},
autoreply: addressData.autoreply || { status: false },
tags: addressData.tags || [],
created: addressData.created
};
if (addressData.metaData) {
values.metaData = tools.formatMetaData(addressData.metaData);
}
if (addressData.internalData) {
values.internalData = tools.formatMetaData(addressData.internalData);
}
res.json(permission.filter(values));
return next();
})
);
server.put(
'/addresses/renameDomain',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
oldDomain: Joi.string().required(),
newDomain: Joi.string().required(),
sess: sessSchema,
ip: sessIPSchema
});
const result = schema.validate(req.params, {
abortEarly: false,
convert: true
});
if (result.error) {
res.status(400);
res.json({
error: result.error.message,
code: 'InputValidationError',
details: tools.validationErrors(result)
});
return next();
}
// permissions check
req.validate(roles.can(req.role).updateAny('addresses'));
let oldDomain = tools.normalizeDomain(result.value.oldDomain);
let newDomain = tools.normalizeDomain(result.value.newDomain);
let updateAddresses = [];
let updateUsers = [];
let cursor = await db.users.collection('addresses').find({
addrview: {
$regex: '@' + tools.escapeRegexStr(oldDomain) + '$'
}
});
let response = {
success: true,
modifiedAddresses: 0,
modifiedUsers: 0,
modifiedDkim: 0,
modifiedAliases: 0
};
let addressData;
try {
while ((addressData = await cursor.next())) {
updateAddresses.push({
updateOne: {
filter: {
_id: addressData._id
},
update: {
$set: {
address: addressData.address.replace(/@.+$/, () => '@' + newDomain),
addrview: addressData.addrview.replace(/@.+$/, () => '@' + newDomain)
}
}
}
});
updateUsers.push({
updateOne: {
filter: {
_id: addressData.user,
address: addressData.address
},
update: {
$set: {
address: addressData.address.replace(/@.+$/, () => '@' + newDomain)
}
}
}
});
}
await cursor.close();
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (updateAddresses.length) {
try {
let r = await db.users.collection('addresses').bulkWrite(updateAddresses, {
ordered: false,
writeConcern: 1
});
response.modifiedAddresses = r.modifiedCount;
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
try {
let r = await db.users.collection('users').bulkWrite(updateUsers, {
ordered: false,
writeConcern: 1
});
response.modifiedUsers = r.modifiedCount;
} catch (err) {
res.status(500);
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
}
// UPDATE DKIM
try {
let r = await db.database.collection('dkim').updateMany(
{
domain: oldDomain
},
{
$set: {
domain: newDomain
}
}
);
response.modifiedDkim = r.modifiedCount;
} catch (err) {
log.error('RenameDomain', 'DKIMERR old=%s new=%s error=%s', oldDomain, newDomain, err.message);
}
// UPDATE ALIASES
try {
let r = await db.users.collection('domainaliases').updateMany(
{
domain: oldDomain
},
{
$set: {
domain: newDomain
}
}
);
response.modifiedAliases = r.modifiedCount;
} catch (err) {
log.error('RenameDomain', 'ALIASERR old=%s new=%s error=%s', oldDomain, newDomain, err.message);
}
await publish(db.redis, {
ev: ADDRESS_DOMAIN_RENAMED,
previous: oldDomain,
current: newDomain
});
res.json(response);
})
);
};