mirror of
https://github.com/nodemailer/wildduck.git
synced 2025-10-29 15:36:34 +08:00
v1.17.0
This commit is contained in:
parent
e5919187a4
commit
feda41d58b
11 changed files with 281 additions and 133 deletions
23
api.js
23
api.js
|
|
@ -12,6 +12,7 @@ const ImapNotifier = require('./lib/imap-notifier');
|
|||
const db = require('./lib/db');
|
||||
const certs = require('./lib/certs');
|
||||
const tools = require('./lib/tools');
|
||||
const consts = require('./lib/consts');
|
||||
const crypto = require('crypto');
|
||||
const Gelf = require('gelf');
|
||||
const os = require('os');
|
||||
|
|
@ -156,7 +157,7 @@ server.use((req, res, next) => {
|
|||
|
||||
server.use(restify.plugins.gzipResponse());
|
||||
|
||||
server.use(restify.plugins.queryParser());
|
||||
server.use(restify.plugins.queryParser({ allowDots: true }));
|
||||
server.use(
|
||||
restify.plugins.bodyParser({
|
||||
maxBodySize: 0,
|
||||
|
|
@ -240,19 +241,18 @@ server.use(
|
|||
.digest('hex');
|
||||
|
||||
if (signature !== tokenData.s) {
|
||||
// rogue token
|
||||
// rogue token or invalidated secret
|
||||
try {
|
||||
await db.redis
|
||||
.multi()
|
||||
.del('tn:token:' + tokenHash)
|
||||
.srem('tn:user:' + tokenData.user, tokenHash)
|
||||
.exec();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
} else if (tokenData.ttl && isNaN(tokenData.ttl) && Number(tokenData.ttl) > 0) {
|
||||
let tokenTTL = Number(tokenData.ttl);
|
||||
let tokenLifetime = config.api.accessControl.tokenLifetime || 30 * 24 * 3600;
|
||||
let tokenLifetime = config.api.accessControl.tokenLifetime || consts.ACCESS_TOKEN_MAX_LIFETIME;
|
||||
|
||||
// check if token is not too old
|
||||
if (tokenLifetime < (Date.now() - Number(tokenData.created)) / 1000) {
|
||||
|
|
@ -261,13 +261,26 @@ server.use(
|
|||
await db.redis
|
||||
.multi()
|
||||
.expire('tn:token:' + tokenHash, tokenTTL)
|
||||
.expire('tn:user:' + tokenData.user, tokenTTL)
|
||||
.exec();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
req.role = tokenData.role;
|
||||
req.user = tokenData.user;
|
||||
req.accessToken = {
|
||||
hash: tokenHash,
|
||||
user: tokenData.user
|
||||
};
|
||||
} else {
|
||||
// expired token, clear it
|
||||
try {
|
||||
await db.redis
|
||||
.multi()
|
||||
.del('tn:token:' + tokenHash)
|
||||
.exec();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
req.role = tokenData.role;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,19 @@ secure=false
|
|||
|
||||
[accessControl]
|
||||
# If true then require a valid access token to perform API calls
|
||||
# If a client provides a token then it is validated even if using a token is not required
|
||||
enabled=false
|
||||
|
||||
# Secret for HMAC
|
||||
# Changing this value invalidates all tokens
|
||||
secret="a secret cat"
|
||||
|
||||
# Generated access token TTL in seconds. Token TTL gets extended by this value every time the token is used. Defaults to 14 days
|
||||
#tokenTTL=1209600
|
||||
|
||||
# Generated access token max lifetime in seconds. Defaults to 180 days
|
||||
#tokenLifetime=15552000
|
||||
|
||||
[roles]
|
||||
# @include "roles.json"
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
define({
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-26T14:41:36.494Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
});
|
||||
define({
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-04-05T12:08:29.034Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-03-26T14:41:36.494Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
}
|
||||
{
"name": "wildduck",
"version": "1.0.0",
"description": "WildDuck API docs",
"title": "WildDuck API",
"url": "https://api.wildduck.email",
"sampleUrl": false,
"defaultVersion": "0.0.0",
"apidoc": "0.3.0",
"generator": {
"name": "apidoc",
"time": "2019-04-05T12:08:29.034Z",
"url": "http://apidocjs.com",
"version": "0.17.7"
}
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ module.exports = (db, server, userHandler) => {
|
|||
} catch (err) {
|
||||
let response = {
|
||||
error: err.message,
|
||||
code: 'AuthFailed' || err.code,
|
||||
code: err.code || 'AuthFailed',
|
||||
id: user.toString()
|
||||
};
|
||||
res.json(response);
|
||||
|
|
@ -213,6 +213,54 @@ module.exports = (db, server, userHandler) => {
|
|||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @api {delete} /authenticate Invalidate authentication token
|
||||
* @apiName DeleteAuth
|
||||
* @apiGroup Authentication
|
||||
* @apiDescription This method invalidates currently used authentication token. If token is not provided then nothing happens
|
||||
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
|
||||
* @apiHeaderExample {json} Header-Example:
|
||||
* {
|
||||
* "X-Access-Token": "59fc66a03e54454869460e45"
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {Boolean} success Indicates successful response
|
||||
*
|
||||
* @apiError error Description of the error
|
||||
* @apiError [code] Error code
|
||||
*
|
||||
* @apiExample {curl} Example usage:
|
||||
* curl -i -XDELETE "http://localhost:8080/authenticate" \
|
||||
* -H 'X-Access-Token: 59fc66a03e54454869460e45'
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "success": true
|
||||
* }
|
||||
*
|
||||
*/
|
||||
server.del(
|
||||
'/authenticate',
|
||||
tools.asyncifyJson(async (req, res, next) => {
|
||||
res.charSet('utf-8');
|
||||
|
||||
if (req.accessToken) {
|
||||
try {
|
||||
await db.redis
|
||||
.multi()
|
||||
.del('tn:token:' + req.accessToken.hash)
|
||||
.exec();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
return next();
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @api {get} /users/:user/authlog List authentication Events
|
||||
* @apiName GetAuthlog
|
||||
|
|
|
|||
|
|
@ -553,6 +553,11 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
* @apiParam {Boolean} [attachments] If true, then matches only messages with attachments
|
||||
* @apiParam {Boolean} [flagged] If true, then matches only messages with \Flagged flags
|
||||
* @apiParam {Boolean} [searchable] If true, then matches messages not in Junk or Trash
|
||||
* @apiParam {Object} [or] Allows to specify some requests as OR (default is AND). At least one of the values in or block must match
|
||||
* @apiParam {String} [or.query] Search string, uses MongoDB fulltext index. Covers data from mesage body and also common headers like from, to, subject etc.
|
||||
* @apiParam {String} [or.from] Partial match for the From: header line
|
||||
* @apiParam {String} [or.to] Partial match for the To: and Cc: header lines
|
||||
* @apiParam {String} [or.subject] Partial match for the Subject: header line
|
||||
* @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
|
||||
|
|
@ -596,6 +601,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
* @apiExample {curl} Example usage:
|
||||
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?query=Ryan"
|
||||
*
|
||||
* @apiExample {curl} Using OR:
|
||||
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?or.from=Ryan&or.to=Ryan"
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
|
|
@ -659,6 +667,23 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
.hex()
|
||||
.length(24)
|
||||
.empty(''),
|
||||
|
||||
or: Joi.object().keys({
|
||||
query: Joi.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.empty(''),
|
||||
from: Joi.string()
|
||||
.trim()
|
||||
.empty(''),
|
||||
to: Joi.string()
|
||||
.trim()
|
||||
.empty(''),
|
||||
subject: Joi.string()
|
||||
.trim()
|
||||
.empty('')
|
||||
}),
|
||||
|
||||
query: Joi.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
|
|
@ -736,6 +761,10 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
let user = new ObjectID(result.value.user);
|
||||
let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : false;
|
||||
let thread = result.value.thread ? new ObjectID(result.value.thread) : false;
|
||||
|
||||
let orTerms = result.value.or || {};
|
||||
let orQuery = [];
|
||||
|
||||
let query = result.value.query;
|
||||
let datestart = result.value.datestart || false;
|
||||
let dateend = result.value.dateend || false;
|
||||
|
|
@ -791,6 +820,9 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
if (query) {
|
||||
filter.searchable = true;
|
||||
filter.$text = { $search: query, $language: 'none' };
|
||||
} else if (orTerms.query) {
|
||||
filter.searchable = true;
|
||||
orQuery.push({ $text: { $search: query, $language: 'none' } });
|
||||
}
|
||||
|
||||
if (mailbox) {
|
||||
|
|
@ -845,6 +877,22 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (orTerms.from) {
|
||||
let regex = orTerms.from.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
orQuery.push({
|
||||
headers: {
|
||||
$elemMatch: {
|
||||
key: 'from',
|
||||
value: {
|
||||
$regex: regex,
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (filterTo) {
|
||||
let regex = filterTo.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
if (!filter.$and) {
|
||||
|
|
@ -879,6 +927,36 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (orTerms.to) {
|
||||
let regex = orTerms.to.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
|
||||
orQuery.push({
|
||||
headers: {
|
||||
$elemMatch: {
|
||||
key: 'to',
|
||||
value: {
|
||||
$regex: regex,
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
orQuery.push({
|
||||
headers: {
|
||||
$elemMatch: {
|
||||
key: 'cc',
|
||||
value: {
|
||||
$regex: regex,
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (filterSubject) {
|
||||
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
if (!filter.$and) {
|
||||
|
|
@ -898,11 +976,31 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
|
|||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (orTerms.subject) {
|
||||
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
orQuery.push({
|
||||
headers: {
|
||||
$elemMatch: {
|
||||
key: 'subject',
|
||||
value: {
|
||||
$regex: regex,
|
||||
$options: 'i'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (filterAttachments) {
|
||||
filter.ha = true;
|
||||
mailboxNeeded = true;
|
||||
}
|
||||
|
||||
if (orQuery.length) {
|
||||
filter.$or = orQuery;
|
||||
}
|
||||
|
||||
if (!mailbox && mailboxNeeded) {
|
||||
// generate a list of mailbox ID values
|
||||
let mailboxes;
|
||||
|
|
|
|||
|
|
@ -90,5 +90,10 @@ module.exports = {
|
|||
// mongdb query TTL limits
|
||||
DB_MAX_TIME_USERS: 1 * 1000,
|
||||
DB_MAX_TIME_MAILBOXES: 800,
|
||||
DB_MAX_TIME_MESSAGES: 10 * 1000
|
||||
DB_MAX_TIME_MESSAGES: 10 * 1000,
|
||||
|
||||
// access token default ttl in seconds (token ttl time is extended every time token is used by this value)
|
||||
ACCESS_TOKEN_DEFAULT_TTL: 14 * 24 * 3600,
|
||||
// access token can be extended until max lifetime value is reached in seconds
|
||||
ACCESS_TOKEN_MAX_LIFETIME: 180 * 24 * 3600
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ class UserHandler {
|
|||
}
|
||||
|
||||
resolveAddress(address, options, callback) {
|
||||
this.asyncResolveAddress(address, options)
|
||||
.catch(err => callback(err))
|
||||
.then(result => callback(null, result));
|
||||
}
|
||||
|
||||
async asyncResolveAddress(address, options) {
|
||||
options = options || {};
|
||||
let wildcard = !!options.wildcard;
|
||||
|
||||
|
|
@ -53,8 +59,9 @@ class UserHandler {
|
|||
removeDots: true
|
||||
});
|
||||
|
||||
let username = address.substr(0, address.indexOf('@'));
|
||||
let domain = address.substr(address.indexOf('@') + 1);
|
||||
let atPos = address.indexOf('@');
|
||||
let username = address.substr(0, atPos);
|
||||
let domain = address.substr(atPos + 1);
|
||||
|
||||
let projection = {
|
||||
user: true,
|
||||
|
|
@ -65,124 +72,95 @@ class UserHandler {
|
|||
projection[key] = true;
|
||||
});
|
||||
|
||||
// try exact match
|
||||
this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@' + domain
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
},
|
||||
(err, addressData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
try {
|
||||
let addressData;
|
||||
// try exact match
|
||||
addressData = await this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@' + domain
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
}
|
||||
);
|
||||
|
||||
if (addressData) {
|
||||
return addressData;
|
||||
}
|
||||
|
||||
// try an alias
|
||||
let aliasDomain;
|
||||
let aliasData = await this.users.collection('domainaliases').findOne(
|
||||
{ alias: domain },
|
||||
{
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
}
|
||||
);
|
||||
|
||||
if (aliasData) {
|
||||
aliasDomain = aliasData.domain;
|
||||
|
||||
addressData = await this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@' + aliasDomain
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
}
|
||||
);
|
||||
|
||||
if (addressData) {
|
||||
return callback(null, addressData);
|
||||
return addressData;
|
||||
}
|
||||
|
||||
let aliasDomain;
|
||||
// try an alias
|
||||
let checkAliases = done => {
|
||||
this.users.collection('domainaliases').findOne(
|
||||
{ alias: domain },
|
||||
{
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
},
|
||||
(err, aliasData) => {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
if (!aliasData) {
|
||||
return done();
|
||||
}
|
||||
|
||||
aliasDomain = aliasData.domain;
|
||||
|
||||
this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@' + aliasDomain
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
},
|
||||
done
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
checkAliases((err, addressData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (addressData) {
|
||||
return callback(null, addressData);
|
||||
}
|
||||
|
||||
if (!wildcard) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
let query = {
|
||||
addrview: '*@' + domain
|
||||
};
|
||||
|
||||
if (aliasDomain) {
|
||||
// search for alias domain as well
|
||||
query.addrview = { $in: [query.addrview, '*@' + aliasDomain] };
|
||||
}
|
||||
|
||||
// try to find a catch-all address
|
||||
this.users.collection('addresses').findOne(
|
||||
query,
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
},
|
||||
(err, addressData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (addressData) {
|
||||
return callback(null, addressData);
|
||||
}
|
||||
|
||||
// try to find a catch-all user (eg. "postmaster@*")
|
||||
this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@*'
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
},
|
||||
(err, addressData) => {
|
||||
if (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!addressData) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
return callback(null, addressData);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (!wildcard) {
|
||||
// wildcard not allowed, so there is nothing else to check for
|
||||
return false;
|
||||
}
|
||||
|
||||
let query = {
|
||||
addrview: '*@' + domain
|
||||
};
|
||||
|
||||
if (aliasDomain) {
|
||||
// search for alias domain as well
|
||||
query.addrview = { $in: [query.addrview, '*@' + aliasDomain] };
|
||||
}
|
||||
|
||||
// try to find a catch-all address
|
||||
addressData = await this.users.collection('addresses').findOne(query, {
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
});
|
||||
|
||||
if (addressData) {
|
||||
return addressData;
|
||||
}
|
||||
|
||||
// try to find a catch-all user (eg. "postmaster@*")
|
||||
addressData = await this.users.collection('addresses').findOne(
|
||||
{
|
||||
addrview: username + '@*'
|
||||
},
|
||||
{
|
||||
projection,
|
||||
maxTimeMS: consts.DB_MAX_TIME_USERS
|
||||
}
|
||||
);
|
||||
|
||||
if (addressData) {
|
||||
return addressData;
|
||||
}
|
||||
} catch (err) {
|
||||
err.code = 'InternalDatabaseError';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// no match was found
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3331,7 +3309,7 @@ class UserHandler {
|
|||
.update(accessToken)
|
||||
.digest('hex');
|
||||
let key = 'tn:token:' + tokenHash;
|
||||
let ttl = config.api.accessControl.tokenTTL || 3600;
|
||||
let ttl = config.api.accessControl.tokenTTL || consts.ACCESS_TOKEN_DEFAULT_TTL;
|
||||
|
||||
let tokenData = {
|
||||
user: user.toString(),
|
||||
|
|
@ -3354,9 +3332,7 @@ class UserHandler {
|
|||
await this.redis
|
||||
.multi()
|
||||
.hmset(key, tokenData)
|
||||
.sadd('tn:user:' + user, tokenHash)
|
||||
.expire(key, ttl)
|
||||
.expire('tn:user:' + user, ttl)
|
||||
.exec();
|
||||
|
||||
return accessToken;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "wildduck",
|
||||
"version": "1.16.2",
|
||||
"version": "1.17.0",
|
||||
"description": "IMAP/POP3 server built with Node.js and MongoDB",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"apidoc": "0.17.7",
|
||||
"browserbox": "0.9.1",
|
||||
"chai": "4.2.0",
|
||||
"eslint": "5.15.3",
|
||||
"eslint": "5.16.0",
|
||||
"eslint-config-nodemailer": "1.2.0",
|
||||
"eslint-config-prettier": "4.1.0",
|
||||
"grunt": "1.0.4",
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
"grunt-shell-spawn": "0.4.0",
|
||||
"grunt-wait": "0.3.0",
|
||||
"icedfrisby": "1.5.0",
|
||||
"mailparser": "2.5.0",
|
||||
"mailparser": "2.6.0",
|
||||
"mocha": "5.2.0",
|
||||
"request": "2.88.0"
|
||||
},
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"libbase64": "1.0.3",
|
||||
"libmime": "4.0.1",
|
||||
"libqp": "1.1.0",
|
||||
"mailsplit": "4.2.4",
|
||||
"mailsplit": "4.3.1",
|
||||
"mobileconfig": "2.2.0",
|
||||
"mongo-cursor-pagination": "7.1.0",
|
||||
"mongodb": "3.2.2",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue