wildduck/lib/api/messages.js
Andris Reinman b6d01842f8 v1.0.58
2017-07-27 16:24:42 +03:00

862 lines
29 KiB
JavaScript

'use strict';
const Joi = require('joi');
const MongoPaging = require('mongo-cursor-pagination');
const addressparser = require('addressparser');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const GridFSBucket = require('mongodb').GridFSBucket;
const libbase64 = require('libbase64');
const libqp = require('libqp');
module.exports = (db, server, messageHandler) => {
server.get({ name: 'messages', path: '/users/:user/mailboxes/:mailbox/messages' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
limit: Joi.number().default(20).min(1).max(250),
order: Joi.any().allow(['asc', 'desc']).default('desc'),
next: Joi.string().alphanum().max(100),
prev: Joi.string().alphanum().max(100),
page: Joi.number().default(1)
});
req.query.user = req.params.user;
req.query.mailbox = req.params.mailbox;
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 user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrev = result.value.prev;
let sortAscending = result.value.order === 'asc';
db.database.collection('mailboxes').findOne({
_id: mailbox,
user
}, {
fields: {
path: true,
specialUse: true
}
}, (err, mailboxData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!mailboxData) {
res.json({
error: 'This mailbox does not exist'
});
return next();
}
let filter = {
mailbox
};
getFilteredMessageCount(db, filter, (err, total) => {
if (err) {
res.json({
error: err.message
});
return next();
}
let opts = {
limit,
query: filter,
fields: {
_id: true,
uid: true,
'meta.from': true,
hdate: true,
subject: true,
'mimeTree.parsedHeader.from': true,
'mimeTree.parsedHeader.sender': true,
ha: true,
intro: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
thread: true
},
paginatedField: 'uid',
sortAscending
};
if (pageNext) {
opts.next = pageNext;
} else if (pagePrev) {
opts.prev = pagePrev;
}
MongoPaging.find(db.users.collection('messages'), opts, (err, result) => {
if (err) {
res.json({
error: result.error.message
});
return next();
}
if (!result.hasPrevious) {
page = 1;
}
let prevUrl = result.hasPrevious
? server.router.render(
'messages',
{ user: user.toString(), mailbox: mailbox.toString() },
{ prev: result.previous, limit, order: sortAscending ? 'asc' : 'desc', page: Math.max(page - 1, 1) }
)
: false;
let nextUrl = result.hasNext
? server.router.render(
'messages',
{ user: user.toString(), mailbox: mailbox.toString() },
{ next: result.next, limit, order: sortAscending ? 'asc' : 'desc', page: page + 1 }
)
: false;
let response = {
success: true,
total,
page,
prev: prevUrl,
next: nextUrl,
specialUse: mailboxData.specialUse,
results: (result.results || []).map(messageData => {
let parsedHeader = (messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let from = parsedHeader.from ||
parsedHeader.sender || [
{
name: '',
address: (messageData.meta && messageData.meta.from) || ''
}
];
tools.decodeAddresses(from);
let response = {
id: messageData.uid,
mailbox,
thread: messageData.thread,
from: from && from[0],
subject: messageData.subject,
date: messageData.hdate.toISOString(),
intro: messageData.intro,
attachments: !!messageData.ha,
seen: !messageData.unseen,
deleted: !messageData.undeleted,
flagged: messageData.flagged,
draft: messageData.draft,
url: server.router.render('message', { user, mailbox, message: messageData.uid })
};
return response;
})
};
res.json(response);
return next();
});
});
});
});
server.get({ name: 'search', path: '/users/:user/search' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
query: Joi.string().trim().max(255).required(),
limit: Joi.number().default(20).min(1).max(250),
next: Joi.string().alphanum().max(100),
prev: Joi.string().alphanum().max(100),
page: Joi.number().default(1)
});
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
});
return next();
}
let user = new ObjectID(result.value.user);
let query = result.value.query;
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrev = result.value.prev;
db.users.collection('users').findOne({
_id: user
}, {
fields: {
username: true,
address: true,
specialUse: true
}
}, (err, userData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!userData) {
res.json({
error: 'This user does not exist'
});
return next();
}
let filter = {
user,
searchable: true,
$text: { $search: query, $language: 'none' }
};
getFilteredMessageCount(db, filter, (err, total) => {
if (err) {
res.json({
error: err.message
});
return next();
}
let opts = {
limit,
query: filter,
fields: {
_id: true,
uid: true,
mailbox: true,
'meta.from': true,
hdate: true,
subject: true,
'mimeTree.parsedHeader.from': true,
'mimeTree.parsedHeader.sender': true,
ha: true,
intro: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
thread: true
},
paginatedField: '_id',
sortAscending: false
};
if (pageNext) {
opts.next = pageNext;
} else if (pagePrev) {
opts.prev = pagePrev;
}
MongoPaging.find(db.users.collection('messages'), opts, (err, result) => {
if (err) {
res.json({
error: result.error.message
});
return next();
}
if (!result.hasPrevious) {
page = 1;
}
let prevUrl = result.hasPrevious
? server.router.render('search', { user: user.toString() }, { prev: result.previous, limit, query, page: Math.max(page - 1, 1) })
: false;
let nextUrl = result.hasNext
? server.router.render('search', { user: user.toString() }, { next: result.next, limit, query, page: page + 1 })
: false;
let response = {
success: true,
query,
total,
page,
prev: prevUrl,
next: nextUrl,
results: (result.results || []).map(messageData => {
let parsedHeader = (messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let from = parsedHeader.from ||
parsedHeader.sender || [
{
name: '',
address: (messageData.meta && messageData.meta.from) || ''
}
];
tools.decodeAddresses(from);
let response = {
id: messageData.uid,
mailbox: messageData.mailbox,
thread: messageData.thread,
from: from && from[0],
subject: messageData.subject,
date: messageData.hdate.toISOString(),
intro: messageData.intro,
attachments: !!messageData.ha,
seen: !messageData.unseen,
deleted: !messageData.undeleted,
flagged: messageData.flagged,
draft: messageData.draft,
url: server.router.render('message', { user, mailbox: messageData.mailbox, message: messageData.uid })
};
return response;
})
};
res.json(response);
return next();
});
});
});
});
server.get({ name: 'message', path: '/users/:user/mailboxes/:mailbox/messages/:message' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
message: Joi.number().min(1).required(),
replaceCidLinks: Joi.boolean().truthy(['Y', 'true', 'yes', 1]).default(false)
});
if (req.query.replaceCidLinks) {
req.params.replaceCidLinks = req.query.replaceCidLinks;
}
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let replaceCidLinks = result.value.replaceCidLinks;
db.users.collection('messages').findOne({
mailbox,
uid: message
}, {
fields: {
_id: true,
user: true,
thread: true,
'meta.from': true,
'meta.to': true,
hdate: true,
'mimeTree.parsedHeader': true,
subject: true,
msgid: true,
exp: true,
rdate: true,
ha: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
attachments: true,
html: true
}
}, (err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist'
});
return next();
}
let parsedHeader = (messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let from = parsedHeader.from ||
parsedHeader.sender || [
{
name: '',
address: (messageData.meta && messageData.meta.from) || ''
}
];
tools.decodeAddresses(from);
let replyTo = parsedHeader['reply-to'];
if (replyTo) {
tools.decodeAddresses(replyTo);
}
let to = parsedHeader.to;
if (to) {
tools.decodeAddresses(to);
}
let cc = parsedHeader.cc;
if (cc) {
tools.decodeAddresses(cc);
}
let list;
if (parsedHeader['list-id'] || parsedHeader['list-unsubscribe']) {
let listId = parsedHeader['list-id'];
if (listId) {
listId = addressparser(listId.toString());
tools.decodeAddresses(listId);
listId = listId.shift();
}
let listUnsubscribe = parsedHeader['list-unsubscribe'];
if (listUnsubscribe) {
listUnsubscribe = addressparser(listUnsubscribe.toString());
tools.decodeAddresses(listUnsubscribe);
}
list = {
id: listId,
unsubscribe: listUnsubscribe
};
}
let expires;
if (messageData.exp) {
expires = new Date(messageData.rdate).toISOString();
}
if (replaceCidLinks) {
messageData.html = (messageData.html || [])
.map(html =>
html.replace(/attachment:([a-f0-9]+)\/(ATT\d+)/g, (str, mid, aid) =>
server.router.render('attachment', { user, mailbox, message, attachment: aid })
)
);
}
res.json({
success: true,
id: message,
from: from[0],
replyTo,
to,
cc,
subject: messageData.subject,
messageId: messageData.msgid,
date: messageData.hdate.toISOString(),
list,
expires,
seen: !messageData.unseen,
deleted: !messageData.undeleted,
flagged: messageData.flagged,
draft: messageData.draft,
html: messageData.html,
attachments: (messageData.attachments || []).map(attachment => {
attachment.url = server.router.render('attachment', { user, mailbox, message, attachment: attachment.id });
return attachment;
}),
raw: server.router.render('attachment', { user, mailbox, message })
});
return next();
});
});
server.get({ name: 'raw', path: '/users/:user/mailboxes/:mailbox/messages/:message/message.eml' }, (req, res, next) => {
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
message: Joi.number().min(1).required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
db.users.collection('messages').findOne({
mailbox,
uid: message
}, {
fields: {
_id: true,
user: true,
mimeTree: true
}
}, (err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist'
});
return next();
}
let response = messageHandler.indexer.rebuild(messageData.mimeTree);
if (!response || response.type !== 'stream' || !response.value) {
res.json({
error: 'This message does not exist'
});
return next();
}
res.setHeader('Content-Type', 'message/rfc822');
response.value.pipe(res);
});
});
server.get({ name: 'attachment', path: '/users/:user/mailboxes/:mailbox/messages/:message/attachments/:attachment' }, (req, res, next) => {
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
message: Joi.number().min(1).required(),
attachment: Joi.string().regex(/^ATT\d+$/i).uppercase().required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let attachment = result.value.attachment;
db.users.collection('messages').findOne({
mailbox,
uid: message,
user
}, {
fields: {
_id: true,
user: true,
attachments: true,
map: true
}
}, (err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist'
});
return next();
}
let attachmentId = messageData.map[attachment];
if (!attachmentId) {
res.json({
error: 'This attachment does not exist'
});
return next();
}
db.database.collection('attachments.files').findOne({
_id: attachmentId
}, (err, attachmentData) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!attachmentData) {
res.json({
error: 'This attachment does not exist'
});
return next();
}
res.writeHead(200, {
'Content-Type': attachmentData.contentType || 'application/octet-stream'
});
let bucket = new GridFSBucket(db.gridfs, {
bucketName: 'attachments'
});
let attachmentStream = bucket.openDownloadStream(attachmentId);
attachmentStream.once('error', err => res.emit('error', err));
if (attachmentData.metadata.transferEncoding === 'base64') {
attachmentStream.pipe(new libbase64.Decoder()).pipe(res);
} else if (attachmentData.metadata.transferEncoding === 'quoted-printable') {
attachmentStream.pipe(new libqp.Decoder()).pipe(res);
} else {
attachmentStream.pipe(res);
}
});
});
});
server.put('/users/:user/mailboxes/:mailbox/messages/:message', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
moveTo: Joi.string().hex().lowercase().length(24),
message: Joi.string().regex(/^\d+(,\d+)*$|^\d+:\d+$|/i).required(),
seen: Joi.boolean().truthy(['Y', 'true', 'yes', 1]),
deleted: Joi.boolean().truthy(['Y', 'true', 'yes', 1]),
flagged: Joi.boolean().truthy(['Y', 'true', 'yes', 1]),
draft: Joi.boolean().truthy(['Y', 'true', 'yes', 1]),
expires: Joi.alternatives().try(Joi.date(), Joi.boolean().truthy(['Y', 'true', 'yes', 1]).allow(false))
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let moveTo = result.value.moveTo ? new ObjectID(result.value.moveTo) : false;
let message = result.value.message;
let messageQuery;
if (/^\d+$/.test(message)) {
messageQuery = Number(message);
} else if (/^\d+(,\d+)*$/.test(message)) {
messageQuery = { $in: message.split(',').map(uid => Number(uid)).sort((a, b) => a - b) };
} else if (/^\d+:\d+$/.test(message)) {
let parts = message.split(':').map(uid => Number(uid)).sort((a, b) => a - b);
if (parts[0] === parts[1]) {
messageQuery = parts[0];
} else {
messageQuery = {
$gte: parts[0],
$lte: parts[1]
};
}
} else {
res.json({
error: 'Invalid message identifier'
});
return next();
}
if (moveTo) {
return messageHandler.move(
{
user,
source: { user, mailbox },
destination: { user, mailbox: moveTo },
updates: result.value,
messageQuery
},
(err, result, info) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!info || !info.destinationUid || !info.destinationUid.length) {
res.json({
error: 'Could not move message, check if message exists'
});
return next();
}
res.json({
success: true,
mailbox: moveTo,
id: info && info.sourceUid && info.sourceUid.map((uid, i) => [uid, info.destinationUid && info.destinationUid[i]])
});
return next();
}
);
}
return messageHandler.update(user, mailbox, messageQuery, result.value, (err, updated) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!updated) {
res.json({
error: 'No message matched query'
});
return next();
}
res.json({
success: true,
updated
});
return next();
});
});
server.del('/users/:user/mailboxes/:mailbox/messages/:message', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
mailbox: Joi.string().hex().lowercase().length(24).required(),
message: Joi.number().min(1).required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
db.database.collection('messages').findOne({
mailbox,
uid: message
}, {
fields: {
_id: true,
user: true,
mailbox: true,
uid: true,
size: true,
map: true,
magic: true,
unseen: true
}
}, (err, messageData) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.status(404);
res.json({
error: 'Message was not found'
});
return next();
}
return messageHandler.del(
{
user,
mailbox: { user, mailbox },
message: messageData
},
err => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success: true
});
return next();
}
);
});
});
};
function getFilteredMessageCount(db, filter, done) {
if (Object.keys(filter).length === 1 && filter.mailbox) {
// try to use cached value to get the count
return tools.getMailboxCounter(db, filter.mailbox, false, done);
}
db.database.collection('messages').count(filter, (err, total) => {
if (err) {
return done(err);
}
done(null, total);
});
}