Updated address resolving

This commit is contained in:
Andris Reinman 2017-12-01 15:04:32 +02:00
parent 6cdbf24faf
commit 55d7b87915
11 changed files with 672 additions and 140 deletions

2
api.js
View file

@ -22,6 +22,7 @@ const updatesRoutes = require('./lib/api/updates');
const authRoutes = require('./lib/api/auth');
const autoreplyRoutes = require('./lib/api/autoreply');
const submitRoutes = require('./lib/api/submit');
const domainaliasRoutes = require('./lib/api/domainaliases');
const serverOptions = {
name: 'Wild Duck API',
@ -141,6 +142,7 @@ module.exports = done => {
authRoutes(db, server, userHandler);
autoreplyRoutes(db, server);
submitRoutes(db, server, messageHandler);
domainaliasRoutes(db, server);
server.on('error', err => {
if (!started) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2017-11-30T14:32:47.076Z", "url": "http://apidocjs.com", "version": "0.17.6" } });
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2017-12-01T13:04:16.194Z", "url": "http://apidocjs.com", "version": "0.17.6" } });

View file

@ -1 +1 @@
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2017-11-30T14:32:47.076Z", "url": "http://apidocjs.com", "version": "0.17.6" } }
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2017-12-01T13:04:16.194Z", "url": "http://apidocjs.com", "version": "0.17.6" } }

View file

@ -81,6 +81,22 @@ indexes:
key:
user: 1
# Indexes for the domainaliases collection
- collection: domainaliases
type: users # index applies to users database
index:
name: domainalias
unique: true
key:
alias: 1
- collection: domainaliases
type: users # index applies to users database
index:
name: domainlist
key:
domain: 1
# Indexes for the application specific passwords collection
- collection: asps

View file

@ -177,7 +177,7 @@ module.exports = (db, server) => {
* @apiDescription Add a new email address for an User. Addresses can contain unicode characters.
* Dots in usernames are normalized so no need to create both "firstlast@example.com" and "first.last@example.com"
*
* Special address <code>*@example.com</code> catches all emails to that domain without a registered destination (requires <code>allowWildcard</code> argument)
* Special addresses <code>*@example.com</code> and <code>username@*</code> catches all emails to these domains or users without a registered destination (requires <code>allowWildcard</code> argument)
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
@ -195,7 +195,7 @@ module.exports = (db, server) => {
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XPOST http://localhost:8080/users/59fc66a03e54454869460e45/addresses/5a1d4541153888cdcd62a71b \
* curl -i -XPOST http://localhost:8080/users/59fc66a03e54454869460e45/addresses \
* -H 'Content-type: application/json' \
* -d '{
* "address": "my.new.address@example.com"
@ -204,7 +204,8 @@ module.exports = (db, server) => {
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* "success": true,
* "id": "59ef21aef255ed1d9d790e81"
* }
*
* @apiErrorExample {json} Error-Response:
@ -231,11 +232,6 @@ module.exports = (db, server) => {
let address = tools.normalizeAddress(req.params.address);
if (/[\u0080-\uFFFF]/.test(req.params.address)) {
// replace unicode characters in email addresses before validation
req.params.address = req.params.address.replace(/[\u0080-\uFFFF]/g, 'x');
}
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
@ -258,18 +254,29 @@ module.exports = (db, server) => {
return next();
}
if ((!result.value.allowWildcard && req.params.address.indexOf('*') === 0) || req.params.address.indexOf('*') > 0) {
res.json({
error: 'Address can not contain *'
});
return next();
}
let wcpos = req.params.address.indexOf('*');
if (result.value.allowWildcard && req.params.address.indexOf('*') === 0 && main) {
res.json({
error: 'Main address can not contain *'
});
return next();
if (wcpos >= 0) {
if (!result.value.allowWildcard) {
res.json({
error: 'Address can not contain *'
});
return next();
}
if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== req.params.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();
}
}
db.users.collection('users').findOne(

490
lib/api/domainaliases.js Normal file
View file

@ -0,0 +1,490 @@
'use strict';
const Joi = require('../joi');
const MongoPaging = require('mongo-cursor-pagination-node6');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
module.exports = (db, server) => {
/**
* @api {get} /addresses List registered Domain Aliases
* @apiName GetAliases
* @apiGroup Domain Aliases
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} [query] Partial match of a Domain Alias or Domain name
* @apiParam {Number} [limit=20] How many records to return
* @apiParam {Number} [page=1] Current page number. Informational only, page numbers start from 1
* @apiParam {Number} [next] Cursor value for next page, retrieved from <code>nextCursor</code> response value
* @apiParam {Number} [previous] Cursor value for previous page, retrieved from <code>previousCursor</code> response value
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Number} total How many results were found
* @apiSuccess {Number} page Current page number. Derived from <code>page</code> query argument
* @apiSuccess {String} previousCursor Either a cursor string or false if there are not any previous results
* @apiSuccess {String} nextCursor Either a cursor string or false if there are not any next results
* @apiSuccess {Object[]} results Aliases listing
* @apiSuccess {String} results.id ID of the Domain Alias
* @apiSuccess {String} results.alias Domain Alias
* @apiSuccess {String} results.domain The domain this alias applies to
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i http://localhost:8080/domainaliases
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "total": 1,
* "page": 1,
* "previousCursor": false,
* "nextCursor": false,
* "results": [
* {
* "id": "59ef21aef255ed1d9d790e81",
* "alias": "example.net",
* "domain": "example.com"
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get({ name: 'domainaliases', path: '/domainaliases' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
query: Joi.string()
.trim()
.empty('')
.max(255),
limit: Joi.number()
.default(20)
.min(1)
.max(250),
next: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
previous: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
page: Joi.number().default(1)
});
const result = Joi.validate(req.query, schema, {
abortEarly: false,
convert: true,
allowUnknown: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
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
? {
$or: [
{
alias: {
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
},
{
domain: {
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
}
]
}
: {};
db.users.collection('domainaliases').count(filter, (err, total) => {
if (err) {
res.json({
error: err.message
});
return next();
}
let opts = {
limit,
query: filter,
fields: {
_id: true,
alias: true,
domain: true
},
paginatedField: 'alias',
sortAscending: true
};
if (pageNext) {
opts.next = pageNext;
} else if (pagePrevious) {
opts.previous = pagePrevious;
}
MongoPaging.find(db.users.collection('domainaliases'), opts, (err, result) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!result.hasPrevious) {
page = 1;
}
let response = {
success: true,
query,
total,
page,
previousCursor: result.hasPrevious ? result.previous : false,
nextCursor: result.hasNext ? result.next : false,
results: (result.results || []).map(domainData => ({
id: domainData._id.toString(),
alias: domainData.alias,
domain: domainData.domain
}))
};
res.json(response);
return next();
});
});
});
/**
* @api {post} /domainaliases/addresses Create new Domain Alias
* @apiName PostDomainAlias
* @apiGroup Domain Aliases
* @apiDescription Add a new Alias for a Domain
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} alias Domain Alias
* @apiParam {String} domain Domain name this Alias applies to
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID of the Domain Alias
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XPOST http://localhost:8080/domainaliases \
* -H 'Content-type: application/json' \
* -d '{
* "domain": "example.com",
* "alias": "example.org"
* }'
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "59ef21aef255ed1d9d790e81"
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This user does not exist"
* }
*/
server.post('/domainaliases', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
alias: Joi.string()
.hostname()
.required(),
domain: Joi.string()
.hostname()
.required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let alias = tools.normalizeDomain(req.params.alias);
let domain = tools.normalizeDomain(req.params.domain);
db.users.collection('domainaliases').findOne(
{
alias
},
(err, aliasData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (aliasData) {
res.json({
error: 'This domain alias already exists'
});
return next();
}
// insert alias address to email address registry
db.users.collection('domainaliases').insertOne(
{
alias,
domain,
created: new Date()
},
(err, r) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
let insertId = r.insertedId;
res.json({
success: !!insertId,
id: insertId
});
return next();
}
);
}
);
});
/**
* @api {get} /domainaliases/:alias Request Alias information
* @apiName GetDomainAlias
* @apiGroup Domain Aliases
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} alias ID of the Alias
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID of the Alias
* @apiSuccess {String} alias Alias domain
* @apiSuccess {String} domain Alias target
* @apiSuccess {String} created Datestring of the time the alias was created
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i http://localhost:8080/domainaliases/59ef21aef255ed1d9d790e7a
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "59ef21aef255ed1d9d790e7a",
* "alias": "example.net",
* "domain": "example.com",
* "created": "2017-10-24T11:19:10.911Z"
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This Alias does not exist"
* }
*/
server.get('/domainaliases/:alias', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
alias: Joi.string()
.hex()
.lowercase()
.length(24)
.required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let alias = new ObjectID(result.value.alias);
db.users.collection('domainaliases').findOne(
{
_id: alias
},
(err, aliasData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!aliasData) {
res.status(404);
res.json({
error: 'Invalid or unknown alias'
});
return next();
}
res.json({
success: true,
id: aliasData._id,
alias: aliasData.alias,
domain: aliasData.domain,
created: aliasData.created
});
return next();
}
);
});
/**
* @api {delete} /domainaliases/:alias Delete an Alias
* @apiName DeleteDomainAlias
* @apiGroup Domain Aliases
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} alias ID of the Alias
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XDELETE http://localhost:8080/domainaliases/59ef21aef255ed1d9d790e81
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.del('/domainaliases/:alias', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
alias: Joi.string()
.hex()
.lowercase()
.length(24)
.required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let alias = new ObjectID(result.value.alias);
db.users.collection('domainaliases').findOne(
{
_id: alias
},
(err, aliasData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!aliasData) {
res.status(404);
res.json({
error: 'Invalid or unknown email alias identifier'
});
return next();
}
// delete address from email address registry
db.users.collection('domainaliases').deleteOne(
{
_id: alias
},
(err, r) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
res.json({
success: !!r.deletedCount
});
return next();
}
);
}
);
});
};

View file

@ -45,6 +45,23 @@ function checkRangeQuery(uids, ne) {
}
}
function normalizeDomain(domain) {
domain = (domain || '').toLowerCase().trim();
try {
if (/^xn--/.test(domain)) {
domain = punycode
.toUnicode(domain)
.normalize('NFC')
.toLowerCase()
.trim();
}
} catch (E) {
// ignore
}
return domain;
}
function normalizeAddress(address, withNames) {
if (typeof address === 'string') {
address = {
@ -59,24 +76,10 @@ function normalizeAddress(address, withNames) {
.normalize('NFC')
.toLowerCase()
.trim();
let domain = address.address
.substr(address.address.lastIndexOf('@') + 1)
.toLowerCase()
.trim();
let encodedDomain = domain;
try {
if (/^xn--/.test(domain)) {
encodedDomain = punycode
.toUnicode(domain)
.normalize('NFC')
.toLowerCase()
.trim();
}
} catch (E) {
// ignore
}
let addr = user + '@' + encodedDomain;
let domain = normalizeDomain(address.address.substr(address.address.lastIndexOf('@') + 1));
let addr = user + '@' + domain;
if (withNames) {
return {
@ -338,6 +341,7 @@ function escapeRegexStr(string) {
module.exports = {
normalizeAddress,
normalizeDomain,
redisConfig,
checkRangeQuery,
decodeAddresses,

View file

@ -65,7 +65,8 @@ class UserHandler {
}
// try to find existing email address
let address = tools.normalizeAddress(username);
let address = tools.normalizeAddress(username).replace(/\+[^@]*@/, '@');
// try exact match
this.users.collection('addresses').findOne(
{
addrview: address.substr(0, address.indexOf('@')).replace(/\./g, '') + address.substr(address.indexOf('@'))
@ -84,28 +85,82 @@ class UserHandler {
return next(null, { _id: addressData.user });
}
// try to find a catch-all address
this.users.collection('addresses').findOne(
{
addrview: '*' + address.substr(address.indexOf('@'))
},
{
fields: {
user: true
}
},
(err, addressData) => {
// try an alias
let checkAliases = done => {
this.users.collection('domainalias').findOne({ alias: address.substr(address.indexOf('@') + 1) }, (err, aliasData) => {
if (err) {
return callback(err);
return done(err);
}
if (!aliasData) {
return done();
}
if (!addressData) {
return callback(null, false);
}
this.users.collection('addresses').findOne(
{
addrview: address.substr(0, address.indexOf('@')).replace(/\./g, '') + '@' + aliasData.domain
},
{
fields: {
user: true
}
},
done
);
});
};
next(null, { _id: addressData.user });
checkAliases((err, addressData) => {
if (err) {
return callback(err);
}
);
if (addressData) {
return next(null, { _id: addressData.user });
}
// try to find a catch-all address
this.users.collection('addresses').findOne(
{
addrview: '*' + address.substr(address.indexOf('@'))
},
{
fields: {
user: true
}
},
(err, addressData) => {
if (err) {
return callback(err);
}
if (addressData) {
next(null, { _id: addressData.user });
}
// try to find a catch-all user
this.users.collection('addresses').findOne(
{
addrview: address.substr(0, address.indexOf('@')).replace(/\./g, '') + +'@*'
},
{
fields: {
user: true
}
},
(err, addressData) => {
if (err) {
return callback(err);
}
if (!addressData) {
return callback(null, false);
}
next(null, { _id: addressData.user });
}
);
}
);
});
}
);
};

122
lmtp.js
View file

@ -8,11 +8,13 @@ const ObjectID = require('mongodb').ObjectID;
const SMTPServer = require('smtp-server').SMTPServer;
const tools = require('./lib/tools');
const MessageHandler = require('./lib/message-handler');
const UserHandler = require('./lib/user-handler');
const FilterHandler = require('./lib/filter-handler');
const db = require('./lib/db');
const certs = require('./lib/certs');
let messageHandler;
let userHandler;
let filterHandler;
let spamChecks, spamHeaderKeys;
@ -69,90 +71,39 @@ const serverOptions = {
// If this method is not set, all addresses are allowed
onRcptTo(rcpt, session, callback) {
let originalRecipient = tools.normalizeAddress(rcpt.address);
let recipient = originalRecipient.replace(/\+[^@]*@/, '@');
let resolveAddress = next => {
db.users.collection('addresses').findOne(
{
addrview: recipient.substr(0, recipient.indexOf('@')).replace(/\./g, '') + recipient.substr(recipient.indexOf('@'))
},
(err, address) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (address) {
return next(null, address);
}
db.users.collection('addresses').findOne(
{
addrview: '*' + recipient.substr(recipient.indexOf('@'))
},
(err, address) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!address) {
return callback(new Error('Unknown recipient'));
}
next(null, address);
}
);
userHandler.get(
originalRecipient,
{
name: true,
forwards: true,
forward: true,
targetUrl: true,
autoreply: true,
encryptMessages: true,
encryptForwarded: true,
pubKey: true
},
(err, userData) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
);
};
resolveAddress((err, address) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!address) {
return callback(new Error('Unknown recipient'));
}
db.users.collection('users').findOne(
{
_id: address.user
},
{
fields: {
name: true,
forwards: true,
forward: true,
targetUrl: true,
autoreply: true,
encryptMessages: true,
encryptForwarded: true,
pubKey: true
}
},
(err, user) => {
if (err) {
log.error('LMTP', err);
return callback(new Error('Database error'));
}
if (!user) {
return callback(new Error('Unknown recipient'));
}
if (!session.users) {
session.users = [];
}
session.users.push({
recipient: originalRecipient,
user
});
callback();
if (!userData) {
return callback(new Error('Unknown recipient'));
}
);
});
if (!session.users) {
session.users = [];
}
session.users.push({
recipient: originalRecipient,
user: userData
});
callback();
}
);
},
// Handle message stream
@ -264,6 +215,13 @@ module.exports = done => {
attachments: config.attachments
});
userHandler = new UserHandler({
database: db.database,
users: db.users,
redis: db.redis,
authlogExpireDays: config.log.authlogExpireDays
});
filterHandler = new FilterHandler({
database: db.database,
users: db.users,