This commit is contained in:
Andris Reinman 2019-03-26 14:17:43 +02:00
parent 84cd3be16c
commit 26f75ff081
13 changed files with 1360 additions and 627 deletions

View file

@ -44,7 +44,7 @@ If you have a blank VPS and a free domain name that you can point to that VPS th
Install script installs and configures all required dependencies and services, including Let's Encrypt based certs, to run WildDuck as a mail server.
Tested on a 10$ DigitalOcean Ubuntu 16.04 instance.
Tested on a 10\$ DigitalOcean Ubuntu 16.04 instance.
![](https://cldup.com/TZoTfxPugm.png)
@ -265,7 +265,7 @@ This is a list of known differences from the IMAP specification. Listed differen
5. `CHARSET` argument for the `SEARCH` command is ignored (RFC3501 6.4.4.)
6. Metadata arguments for `SEARCH MODSEQ` are ignored (RFC7162 3.1.5.). You can define `<entry-name>` and `<entry-type-req>` values but these are not used for
anything
7. `SEARCH TEXT` and `SEARCH BODY` both use MongoDB [$text index](https://docs.mongodb.com/v3.4/reference/operator/query/text/) against decoded plaintext
7. `SEARCH TEXT` and `SEARCH BODY` both use MongoDB [\$text index](https://docs.mongodb.com/v3.4/reference/operator/query/text/) against decoded plaintext
version of the message. RFC3501 assumes that it should be a string match either against full message (`TEXT`) or body section (`BODY`).
8. What happens when FETCH is called for messages that were deleted in another session? _Not sure, need to check_
9. **Autoexpunge**, meaning that an EXPUNGE is called on background whenever a messages gets a `\Deleted` flag set. This is not in conflict with IMAP RFCs.
@ -343,6 +343,10 @@ sh.enableSharding('attachments');
// attachment _id is a sha256 hash of attachment contents
sh.shardCollection('attachments.attachments.files', { _id: 'hashed' });
sh.shardCollection('attachments.attachments.chunks', { files_id: 'hashed' });
// storage _id is an ObjectID
sh.shardCollection('attachments.storage.files', { _id: 'hashed' });
sh.shardCollection('attachments.storage.chunks', { files_id: 'hashed' });
```
### Disk usage

13
api.js
View file

@ -7,6 +7,7 @@ const logger = require('restify-logger');
const UserHandler = require('./lib/user-handler');
const MailboxHandler = require('./lib/mailbox-handler');
const MessageHandler = require('./lib/message-handler');
const StorageHandler = require('./lib/storage-handler');
const ImapNotifier = require('./lib/imap-notifier');
const db = require('./lib/db');
const certs = require('./lib/certs');
@ -20,6 +21,7 @@ const usersRoutes = require('./lib/api/users');
const addressesRoutes = require('./lib/api/addresses');
const mailboxesRoutes = require('./lib/api/mailboxes');
const messagesRoutes = require('./lib/api/messages');
const storageRoutes = require('./lib/api/storage');
const filtersRoutes = require('./lib/api/filters');
const aspsRoutes = require('./lib/api/asps');
const totpRoutes = require('./lib/api/2fa/totp');
@ -35,6 +37,7 @@ const dkimRoutes = require('./lib/api/dkim');
let userHandler;
let mailboxHandler;
let messageHandler;
let storageHandler;
let notifier;
let loggelf;
@ -371,6 +374,13 @@ module.exports = done => {
loggelf: message => loggelf(message)
});
storageHandler = new StorageHandler({
database: db.database,
users: db.users,
gridfs: db.gridfs,
loggelf: message => loggelf(message)
});
userHandler = new UserHandler({
database: db.database,
users: db.users,
@ -393,7 +403,8 @@ module.exports = done => {
usersRoutes(db, server, userHandler);
addressesRoutes(db, server);
mailboxesRoutes(db, server, mailboxHandler);
messagesRoutes(db, server, messageHandler, userHandler);
messagesRoutes(db, server, messageHandler, userHandler, storageHandler);
storageRoutes(db, server, storageHandler);
filtersRoutes(db, server);
aspsRoutes(db, server, userHandler);
totpRoutes(db, server, userHandler);

View file

@ -40,6 +40,13 @@
"delete:any": ["*"]
},
"storage": {
"create:any": ["*"],
"read:any": ["*"],
"update:any": ["*"],
"delete:any": ["*"]
},
"mailboxes": {
"create:any": ["*"],
"read:any": ["*"],
@ -170,6 +177,13 @@
"delete:any": ["*"]
},
"storage": {
"create:any": ["*"],
"read:any": ["*"],
"update:any": ["*"],
"delete:any": ["*"]
},
"mailboxes": {
"create:any": ["*"],
"read:any": ["*"],
@ -230,6 +244,13 @@
"delete:own": ["*"]
},
"storage": {
"create:own": ["*"],
"read:own": ["*"],
"update:own": ["*"],
"delete:own": ["*"]
},
"mailboxes": {
"create:own": ["*"],
"read:own": ["*"],

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", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2019-03-21T08:29:21.845Z", "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-03-26T12:14:03.120Z", "url": "http://apidocjs.com", "version": "0.17.7" } });

View file

@ -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-21T08:29:21.845Z", "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-03-26T12:14:03.120Z", "url": "http://apidocjs.com", "version": "0.17.7" } }

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,5 @@
'use strict';
// TODO: finish converting methods to async..await with ACL
const config = require('wild-config');
const log = require('npmlog');
const libmime = require('libmime');
@ -20,7 +18,7 @@ const Maildropper = require('../maildropper');
const util = require('util');
const roles = require('../roles');
module.exports = (db, server, messageHandler, userHandler) => {
module.exports = (db, server, messageHandler, userHandler, storageHandler) => {
let maildrop = new Maildropper({
db,
zone: config.sender.zone,
@ -1945,6 +1943,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiParam {Object[]} [headers] Custom headers for the message. If reference message is set then In-Reply-To and References headers are set automaticall y
* @apiParam {String} headers.key Header key ('X-Mailer')
* @apiParam {String} headers.value Header value ('My Awesome Mailing Service')
* @apiParam {String[]} [files] Attachments as storage file IDs. These attachments are also listed to message metaData.attachedFiles array
* @apiParam {Object[]} [attachments] Attachments for the message
* @apiParam {String} attachments.content Base64 encoded attachment content
* @apiParam {String} [attachments.filename] Attachment filename
@ -2111,6 +2110,13 @@ module.exports = (db, server, messageHandler, userHandler) => {
.empty('')
.max(1024 * 1024),
files: Joi.array().items(
Joi.string()
.hex()
.lowercase()
.length(24)
),
attachments: Joi.array().items(
Joi.object().keys({
filename: Joi.string()
@ -2196,10 +2202,11 @@ module.exports = (db, server, messageHandler, userHandler) => {
return next();
}
let metaData;
if (result.value.metaData) {
try {
let value = JSON.parse(result.value.metaData);
if (!value || typeof value !== 'object') {
metaData = JSON.parse(result.value.metaData);
if (!metaData || typeof metaData !== 'object') {
throw new Error('Not an object');
}
} catch (err) {
@ -2224,7 +2231,6 @@ module.exports = (db, server, messageHandler, userHandler) => {
let date = result.value.date || new Date();
let mailboxData;
try {
mailboxData = await db.database.collection('mailboxes').findOne({
_id: mailbox,
@ -2288,6 +2294,31 @@ module.exports = (db, server, messageHandler, userHandler) => {
result.value.draft = true; // only draft messages can reference to another message
}
if (result.value.files && result.value.files.length) {
for (let file of result.value.files) {
try {
let fileData = await storageHandler.get(userData._id, new ObjectID(file));
if (fileData) {
if (!metaData) {
metaData = {};
}
if (!metaData.attachedFiles) {
metaData.attachedFiles = [];
}
extraAttachments.push(fileData);
metaData.attachedFiles.push({
id: fileData.id.toString(),
filename: fileData.filename,
contentType: fileData.contentType,
size: fileData.size
});
}
} catch (err) {
log.error('API', 'STORAGEFAIL user=%s file=%s error=%s', userData._id, file, err.message);
}
}
}
let data = {
from: result.value.from || { name: userData.name, address: userData.address },
date,
@ -2368,7 +2399,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
origin: result.value.ip || '127.0.0.1',
transtype: 'UPLOAD',
time: date,
custom: result.value.metaData || '',
custom: (metaData ? JSON.stringify(metaData) : result.value.metaData) || '',
reference: referencedMessage
? {
action: result.value.reference.action,
@ -2665,6 +2696,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiParam {String} user ID of the User
* @apiParam {String} mailbox ID of the Mailbox
* @apiParam {Number} message Message ID
* @apiParam {Boolean} deleteFiles If true then deletes attachment files listed in metaData.attachedFiles array
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} queueId Message ID in outbound queue
@ -2677,7 +2709,9 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiExample {curl} Submit a Message:
* curl -i -XPOST "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a13e54454869460e57/messages/1/submit" \
* -H 'Content-type: application/json' \
* -d '{}'
* -d '{
* "deleteFiles": true
* }'
*
* @apiSuccessExample {json} Submit Response:
* HTTP/1.1 200 OK
@ -2713,6 +2747,9 @@ module.exports = (db, server, messageHandler, userHandler) => {
.length(24)
.required(),
message: Joi.number().required(),
deleteFiles: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
@ -2743,6 +2780,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let deleteFiles = result.value.deleteFiles;
let userData;
try {
@ -2796,6 +2834,13 @@ module.exports = (db, server, messageHandler, userHandler) => {
return next();
}
let metaData;
try {
metaData = messageData.meta.custom ? JSON.parse(messageData.meta.custom) : false;
} catch (err) {
// ignore
}
let envelope = messageData.meta.envelope;
if (!envelope) {
// fetch envelope data from message headers
@ -2891,6 +2936,16 @@ module.exports = (db, server, messageHandler, userHandler) => {
}
}
if (deleteFiles && metaData && metaData.attachedFiles) {
for (let fileData of metaData.attachedFiles) {
try {
await storageHandler.delete(userData._id, new ObjectID(fileData.id));
} catch (err) {
log.error('API', 'STORAGEDELFAIL user=%s file=%s error=%s', userData._id, fileData.id, err.message);
}
}
}
res.json(response);
return next();
})

554
lib/api/storage.js Normal file
View file

@ -0,0 +1,554 @@
'use strict';
const Joi = require('../joi');
const MongoPaging = require('mongo-cursor-pagination');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const roles = require('../roles');
module.exports = (db, server, storageHandler) => {
/**
* @api {post} /users/:user/storage Upload File
* @apiName UploadStorage
* @apiGroup Storage
* @apiDescription This method allows to upload an attachment to be linked from a draft
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} content Base64 encoded file content
* @apiParam {String} [filename] Filename
* @apiParam {String} [contentType] MIME type for the file
* @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Object} id File ID
*
* @apiError error Description of the error
*
* @apiExample {curl} Upload a file:
* curl -i -XPOST "http://localhost:8080/users/5a2f9ca57308fc3a6f5f811d/storage" \
* -H 'Content-type: application/json' \
* -d '{
* "content": "aGVsbG93IGZyb20=",
* "filename": "test.txt"
* }'
*
* @apiSuccessExample {json} Forward Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "5a2f9ca57308fc3a6f5f811e"
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.post(
'/users/:user/storage',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
filename: Joi.string()
.empty('')
.max(255),
contentType: Joi.string()
.empty('')
.max(255),
encoding: Joi.string()
.empty('')
.default('base64'),
content: Joi.string().required(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).createOwn('storage'));
} else {
req.validate(roles.can(req.role).createAny('storage'));
}
let user = new ObjectID(result.value.user);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
address: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let id = await storageHandler.add(user, result.value);
res.json({
success: !!id,
id
});
return next();
})
);
/**
* @api {get} /users/:user/storage List stored files
* @apiName GetStorage
* @apiGroup Storage
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} [query] Partial match of a filename
* @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 File listing
* @apiSuccess {String} results.id ID of the File
* @apiSuccess {String} results.filename Filename
* @apiSuccess {String} results.contentType Content-Type of the file
* @apiSuccess {Number} results.size File size
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i http://localhost:8080/users/59fc66a03e54454869460e45/storage
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "total": 1,
* "page": 1,
* "previousCursor": false,
* "nextCursor": false,
* "results": [
* {
* "id": "59ef21aef255ed1d9d790e81",
* "filename": "hello.txt",
* "size": 1024
* },
* {
* "id": "59ef21aef255ed1d9d790e82",
* "filename": "finances.xls",
* "size": 2084
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
'/users/:user/storage',
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),
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),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
req.query.user = req.params.user;
const result = Joi.validate(req.query, schema, {
abortEarly: false,
convert: true,
allowUnknown: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('storage'));
} else {
req.validate(roles.can(req.role).readAny('storage'));
}
let user = new ObjectID(result.value.user);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
address: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
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 && {
'metadata.user': user,
filename: {
$regex: query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'),
$options: ''
}
}) || {
'metadata.user': user
};
let total = await db.gridfs.collection('storage.files').countDocuments(filter);
let opts = {
limit,
query: filter,
paginatedField: 'filename',
sortAscending: true
};
if (pageNext) {
opts.next = pageNext;
} else if ((!page || page > 1) && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.gridfs.collection('storage.files'), opts);
} catch (err) {
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(fileData => ({
id: fileData._id.toString(),
filename: fileData.filename || false,
contentType: fileData.contentType || false,
size: fileData.length,
created: fileData.uploadDate.toISOString(),
md5: fileData.md5
}))
};
res.json(response);
return next();
})
);
/**
* @api {delete} /users/:user/storage/:file Delete a File
* @apiName DeleteStorage
* @apiGroup Storage
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} address ID of the File
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i -XDELETE http://localhost:8080/users/59ef21aef255ed1d9d790e7a/storage/59ef21aef255ed1d9d790e81
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Trying to delete main address. Set a new main address first"
* }
*/
server.del(
'/users/:user/storage/:file',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
file: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
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('storage'));
} else {
req.validate(roles.can(req.role).deleteAny('storage'));
}
let file = new ObjectID(result.value.file);
await storageHandler.delete(user, file);
res.json({
success: true
});
return next();
})
);
/**
* @api {get} /users/:user/storage/:file Download File
* @apiName GetStorageFile
* @apiGroup Storage
* @apiDescription This method returns stored file contents in binary form
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} user ID of the User
* @apiParam {String} file ID of the File
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/storage/59fc66a13e54454869460e57"
*
* @apiSuccessExample {text} Success-Response:
* HTTP/1.1 200 OK
* Content-Type: image/png
*
* <89>PNG...
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This attachment does not exist"
* }
*/
server.get(
{ name: 'storagefile', path: '/users/:user/storage/:file' },
tools.asyncifyJson(async (req, res, next) => {
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
file: 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,
code: 'InputValidationError'
});
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('storage'));
} else {
req.validate(roles.can(req.role).readAny('storage'));
}
let user = new ObjectID(result.value.user);
let file = new ObjectID(result.value.file);
let fileData;
try {
fileData = await db.gridfs.collection('storage.files').findOne({
_id: file,
'metadata.user': user
});
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!fileData) {
res.json({
error: 'This file does not exist',
code: 'FileNotFound'
});
return next();
}
res.writeHead(200, {
'Content-Type': fileData.contentType || 'application/octet-stream'
});
let stream = storageHandler.gridstore.openDownloadStream(file);
stream.once('error', err => {
try {
res.end(err.message);
} catch (err) {
//ignore
}
});
stream.pipe(res);
})
);
};

View file

@ -1,77 +0,0 @@
'use strict';
// TODO: handle drafts
/*
{
_id: "aaaaa",
user: "bbbbb",
reference: 'message_id',
action: 'reply',
references: ['from-ref-message'],
inReplyTo: ['from-ref-message'],
messageId: '<draft.id@local>',
date: date_obj,
from: identity_ref,
to: [{name, address}],
cc: [{name, address}],
bcc: [{name, address}],
subject: 'test',
html: '<html>',
attachments: [
{
filename: 'aaa.jpg',
contentType: 'image/jpeg',
content: binary,
cid: 'only.for@embedded.images'
}
]
}
*/
class DraftHandler {
constructor(options) {
this.database = options.database;
this.redis = options.redis;
}
// should create a new Draft object and return ID
create(user, options, callback) {
options = options || {};
callback(new Error('Future feature'));
}
// should retrieve draft info
get(user, draft, callback) {
callback(new Error('Future feature'));
}
// should add new attachment to draft and return attachment ID
addAttachment(user, draft, attachmentData, callback) {
callback(new Error('Future feature'));
}
// should delete an attachment from a draft
deleteAttachment(user, draft, attachment, callback) {
callback(new Error('Future feature'));
}
// should submit message to queue and delete draft
send(user, draft, envelope, callback) {
callback(new Error('Future feature'));
}
// should cancel the draft and delete contents
discard(user, draft, callback) {
callback(new Error('Future feature'));
}
}
module.exports = DraftHandler;

140
lib/storage-handler.js Normal file
View file

@ -0,0 +1,140 @@
'use strict';
const GridFSBucket = require('mongodb').GridFSBucket;
const libbase64 = require('libbase64');
const libmime = require('libmime');
class StorageHandler {
constructor(options) {
this.database = options.database;
this.gridfs = options.gridfs || options.database;
this.users = options.users || options.database;
this.bucketName = 'storage';
this.gridstore = new GridFSBucket(this.gridfs, {
bucketName: this.bucketName,
chunkSizeBytes: 255 * 1024
});
}
add(user, options) {
return new Promise((resolve, reject) => {
let filename = options.filename;
let contentType = options.contentType;
let filebase = 'upload-' + new Date().toISOString().substr(0, 10);
if (!contentType && !filename) {
filename = filebase + '.bin';
contentType = 'application/octet-stream';
} else if (!contentType) {
contentType = libmime.detectMimeType(filename) || 'application/octet-stream';
} else {
filename = filebase + '.' + libmime.detectExtension(contentType);
}
let store = this.gridstore.openUploadStream(filename, {
contentType,
metadata: {
user
}
});
store.on('error', err => {
reject(err);
});
store.once('finish', () => {
resolve(store.id);
});
let decoder = new libbase64.Decoder();
decoder.pipe(store);
decoder.once('error', err => {
// pass error forward
store.emit('error', err);
});
try {
decoder.end(options.content);
} catch (err) {
return reject(err);
}
});
}
get(user, file) {
return new Promise((resolve, reject) => {
this.gridfs.collection('storage.files').findOne(
{
_id: file,
'metadata.user': user
},
(err, fileData) => {
if (err) {
return reject(err);
}
if (!fileData) {
let err = 'This file does not exist';
err.code = 'FileNotFound';
return reject(err);
}
let stream = this.gridstore.openDownloadStream(file);
let chunks = [];
let chunklen = 0;
stream.once('error', err => {
reject(err);
});
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.once('end', () => {
resolve({
id: fileData._id,
filename: fileData.filename,
contentType: fileData.contentType,
size: fileData.length,
content: Buffer.concat(chunks, chunklen)
});
});
}
);
});
}
delete(user, file) {
return new Promise((resolve, reject) => {
this.gridfs.collection('storage.files').findOne(
{
_id: file,
'metadata.user': user
},
(err, fileData) => {
if (err) {
return reject(err);
}
if (!fileData) {
let err = 'This file does not exist';
err.code = 'FileNotFound';
return reject(err);
}
this.gridstore.delete(file, err => {
if (err) {
return reject(err);
}
return resolve();
});
}
);
});
}
}
module.exports = StorageHandler;

View file

@ -1,6 +1,6 @@
{
"name": "wildduck",
"version": "1.15.2",
"version": "1.16.0",
"description": "IMAP/POP3 server built with Node.js and MongoDB",
"main": "server.js",
"scripts": {
@ -22,7 +22,7 @@
"eslint": "5.15.3",
"eslint-config-nodemailer": "1.2.0",
"eslint-config-prettier": "4.1.0",
"grunt": "1.0.3",
"grunt": "1.0.4",
"grunt-cli": "1.3.2",
"grunt-eslint": "21.0.0",
"grunt-mocha-test": "0.13.3",
@ -41,14 +41,14 @@
"gelf": "2.0.1",
"generate-password": "1.4.1",
"he": "1.2.0",
"html-to-text": "4.0.0",
"html-to-text": "5.0.0",
"humanname": "0.2.2",
"iconv-lite": "0.4.24",
"ioredfour": "1.0.2-ioredis-02",
"ioredis": "4.6.2",
"ioredis": "4.9.0",
"isemail": "3.2.0",
"joi": "14.3.1",
"js-yaml": "3.12.2",
"js-yaml": "3.13.0",
"key-fingerprint": "1.1.0",
"libbase64": "1.0.3",
"libmime": "4.0.1",
@ -56,10 +56,10 @@
"mailsplit": "4.2.4",
"mobileconfig": "2.2.0",
"mongo-cursor-pagination": "7.1.0",
"mongodb": "3.1.13",
"mongodb": "3.2.2",
"mongodb-extended-json": "1.10.1",
"node-forge": "0.8.2",
"nodemailer": "5.1.1",
"nodemailer": "6.0.0",
"npmlog": "4.1.2",
"openpgp": "4.4.10",
"pem": "1.14.2",