wildduck/lib/api/messages.js

3429 lines
126 KiB
JavaScript

'use strict';
// TODO: finish converting methods to async..await with ACL
const config = require('wild-config');
const log = require('npmlog');
const Joi = require('../joi');
const MongoPaging = require('mongo-cursor-pagination');
const addressparser = require('nodemailer/lib/addressparser');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const consts = require('../consts');
const libbase64 = require('libbase64');
const libqp = require('libqp');
const forward = require('../forward');
const Maildropper = require('../maildropper');
const util = require('util');
const roles = require('../roles');
module.exports = (db, server, messageHandler) => {
let maildrop = new Maildropper({
db,
zone: config.sender.zone,
collection: config.sender.collection,
gfs: config.sender.gfs
});
const updateMessage = util.promisify(messageHandler.update.bind(messageHandler));
const deleteMessage = util.promisify(messageHandler.del.bind(messageHandler));
const encryptMessage = util.promisify(messageHandler.encryptMessage.bind(messageHandler));
const getAttachmentData = util.promisify(messageHandler.attachmentStorage.get.bind(messageHandler.attachmentStorage));
const addMessage = util.promisify((...args) => {
let callback = args.pop();
messageHandler.add(...args, (err, status, data) => {
if (err) {
return callback(err);
}
return callback(null, { status, data });
});
});
const moveMessage = util.promisify((...args) => {
let callback = args.pop();
messageHandler.move(...args, (err, result, info) => {
if (err) {
return callback(err);
}
return callback(null, { result, info });
});
});
const putMessageHandler = async (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', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
deleted: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
flagged: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
draft: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
expires: Joi.alternatives().try(
Joi.date(),
Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.allow(false)
),
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).updateOwn('messages'));
} else {
req.validate(roles.can(req.role).updateAny('messages'));
}
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',
code: 'NoSuchMessage'
});
return next();
}
if (moveTo) {
let info;
try {
let data = await moveMessage({
user,
source: { user, mailbox },
destination: { user, mailbox: moveTo },
updates: result.value,
messageQuery
});
info = data.info;
} catch (err) {
res.json({
error: err.message,
code: err.code
});
return next();
}
if (!info || !info.destinationUid || !info.destinationUid.length) {
res.json({
error: 'Could not move message, check if message exists',
code: 'NoSuchMessage'
});
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();
}
let updated;
try {
updated = await updateMessage(user, mailbox, messageQuery, result.value);
} catch (err) {
res.json({
error: err.message,
code: err.code
});
return next();
}
if (!updated) {
res.json({
error: 'No message matched query',
code: 'NoSuchMessage'
});
return next();
}
res.json({
success: true,
updated
});
return next();
};
/**
* @api {get} /users/:user/mailboxes/:mailbox/messages List messages in a Mailbox
* @apiName GetMessages
* @apiGroup Messages
* @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} mailbox ID of the Mailbox
* @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} [order="desc"] Ordering of the records by insert date
* @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 Message listing
* @apiSuccess {Number} results.id ID of the Message
* @apiSuccess {String} results.mailbox ID of the Mailbox
* @apiSuccess {String} results.thread ID of the Thread
* @apiSuccess {Object} results.from Sender info
* @apiSuccess {String} results.from.name Name of the sender
* @apiSuccess {String} results.from.address Address of the sender
* @apiSuccess {Object[]} results.to Recipients in To: field
* @apiSuccess {String} results.to.name Name of the recipient
* @apiSuccess {String} results.to.address Address of the recipient
* @apiSuccess {Object[]} results.cc Recipients in Cc: field
* @apiSuccess {String} results.cc.name Name of the recipient
* @apiSuccess {String} results.cc.address Address of the recipient
* @apiSuccess {String} results.subject Message subject
* @apiSuccess {String} results.date Datestring
* @apiSuccess {String} results.intro First 128 bytes of the message
* @apiSuccess {Boolean} results.attachments Does the message have attachments
* @apiSuccess {Boolean} results.seen Is this message alread seen or not
* @apiSuccess {Boolean} results.deleted Does this message have a \\Deleted flag (should not have as messages are automatically deleted once this flag is set)
* @apiSuccess {Boolean} results.flagged Does this message have a \\Flagged flag
* @apiSuccess {Boolean} results.answered Does this message have a \\Answered flag
* @apiSuccess {Boolean} results.forwarded Does this message have a \$Forwarded flag
* @apiSuccess {Object} results.contentType Parsed Content-Type header. Usually needed to identify encrypted messages and such
* @apiSuccess {String} results.contentType.value MIME type of the message, eg. "multipart/mixed"
* @apiSuccess {Object} results.contentType.params An object with Content-Type params as key-value pairs
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "total": 1,
* "page": 1,
* "previousCursor": false,
* "nextCursor": false,
* "specialUse": null,
* "results": [
* {
* "id": 1,
* "mailbox": "59fc66a03e54454869460e46",
* "thread": "59fc66a13e54454869460e50",
* "from": {
* "address": "rfinnie@domain.dom",
* "name": "Ryan Finnie"
* },
* "subject": "Ryan Finnie's MIME Torture Test v1.0",
* "date": "2003-10-24T06:28:34.000Z",
* "intro": "Welcome to Ryan Finnie's MIME torture test. This message was designed to introduce a couple of the newer features of MIME-aware…",
* "attachments": true,
* "seen": true,
* "deleted": false,
* "flagged": true,
* "draft": false,
* "answered": false,
* "forwarded": false,
* "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1",
* "contentType": {
* "value": "multipart/mixed",
* "params": {
* "boundary": "=-qYxqvD9rbH0PNeExagh1"
* }
* }
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'messages', path: '/users/:user/mailboxes/:mailbox/messages' },
tools.asyncifyJson(async (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()
.empty('')
.default(20)
.min(1)
.max(250),
order: Joi.any()
.empty('')
.allow(['asc', 'desc'])
.default('desc'),
next: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
previous: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
page: Joi.number()
.empty('')
.default(1),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
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,
code: 'InputValidationError'
});
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
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 pagePrevious = result.value.previous;
let sortAscending = result.value.order === 'asc';
let mailboxData;
try {
mailboxData = await db.database.collection('mailboxes').findOne(
{
_id: mailbox,
user
},
{
projection: {
path: true,
specialUse: true,
uidNext: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!mailboxData) {
res.json({
error: 'This mailbox does not exist'
});
return next();
}
let filter = {
mailbox,
// uid is part of the sharding key so we need it somehow represented in the query
uid: {
$gt: 0,
$lt: mailboxData.uidNext
}
};
let total = await util.promisify(getFilteredMessageCount)(db, filter);
let opts = {
limit,
query: filter,
fields: {
idate: true,
// FIXME: MongoPaging inserts fields value as second argument to col.find()
projection: {
_id: true,
uid: true,
msgid: true,
mailbox: true,
'meta.from': true,
hdate: true,
idate: true,
subject: true,
'mimeTree.parsedHeader.from': true,
'mimeTree.parsedHeader.to': true,
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.sender': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
thread: true,
flags: true
}
},
paginatedField: 'idate',
sortAscending
};
if (pageNext) {
opts.next = pageNext;
} else if (page > 1 && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.database.collection('messages'), opts);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!listing.hasPrevious) {
page = 1;
}
let response = {
success: true,
total,
page,
previousCursor: listing.hasPrevious ? listing.previous : false,
nextCursor: listing.hasNext ? listing.next : false,
specialUse: mailboxData.specialUse,
results: (listing.results || []).map(formatMessageListing)
};
res.json(response);
return next();
})
);
/**
* @api {get} /users/:user/search Search for messages
* @apiName GetMessagesSearch
* @apiGroup Messages
* @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} [mailbox] ID of the Mailbox
* @apiParam {String} [query] Search string, uses MongoDB fulltext index. Covers data from mesage body and also common headers like from, to, subject etc.
* @apiParam {String} [datestart] Datestring for the earliest message storing time
* @apiParam {String} [dateend] Datestring for the latest message storing time
* @apiParam {String} [from] Partial match for the From: header line
* @apiParam {String} [to] Partial match for the To: and Cc: header lines
* @apiParam {String} [subject] Partial match for the Subject: header line
* @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 {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 Message listing
* @apiSuccess {Number} results.id ID of the Message
* @apiSuccess {String} results.mailbox ID of the Mailbox
* @apiSuccess {String} results.thread ID of the Thread
* @apiSuccess {Object} results.from Sender info
* @apiSuccess {String} results.from.name Name of the sender
* @apiSuccess {String} results.from.address Address of the sender
* @apiSuccess {Object[]} results.to Recipients in To: field
* @apiSuccess {String} results.to.name Name of the recipient
* @apiSuccess {String} results.to.address Address of the recipient
* @apiSuccess {Object[]} results.cc Recipients in Cc: field
* @apiSuccess {String} results.cc.name Name of the recipient
* @apiSuccess {String} results.cc.address Address of the recipient
* @apiSuccess {String} results.subject Message subject
* @apiSuccess {String} results.date Datestring
* @apiSuccess {String} results.intro First 128 bytes of the message
* @apiSuccess {Boolean} results.attachments Does the message have attachments
* @apiSuccess {Boolean} results.seen Is this message alread seen or not
* @apiSuccess {Boolean} results.deleted Does this message have a \Deleted flag (should not have as messages are automatically deleted once this flag is set)
* @apiSuccess {Boolean} results.flagged Does this message have a \Flagged flag
* @apiSuccess {String} results.url Relative API url for fetching message contents
* @apiSuccess {Object} results.contentType Parsed Content-Type header. Usually needed to identify encrypted messages and such
* @apiSuccess {String} results.contentType.value MIME type of the message, eg. "multipart/mixed"
* @apiSuccess {Object} results.contentType.params An object with Content-Type params as key-value pairs
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/search?query=Ryan"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "query": "Ryan",
* "total": 1,
* "page": 1,
* "previousCursor": false,
* "nextCursor": false,
* "specialUse": null,
* "results": [
* {
* "id": 1,
* "mailbox": "59fc66a03e54454869460e46",
* "thread": "59fc66a13e54454869460e50",
* "from": {
* "address": "rfinnie@domain.dom",
* "name": "Ryan Finnie"
* },
* "subject": "Ryan Finnie's MIME Torture Test v1.0",
* "date": "2003-10-24T06:28:34.000Z",
* "intro": "Welcome to Ryan Finnie's MIME torture test. This message was designed to introduce a couple of the newer features of MIME-aware…",
* "attachments": true,
* "seen": true,
* "deleted": false,
* "flagged": true,
* "draft": false,
* "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1",
* "contentType": {
* "value": "multipart/mixed",
* "params": {
* "boundary": "=-qYxqvD9rbH0PNeExagh1"
* }
* }
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'search', path: '/users/:user/search' },
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
mailbox: Joi.string()
.hex()
.length(24)
.empty(''),
query: Joi.string()
.trim()
.max(255)
.empty(''),
datestart: Joi.date()
.label('Start time')
.empty(''),
dateend: Joi.date()
.label('End time')
.empty(''),
from: Joi.string()
.trim()
.empty(''),
to: Joi.string()
.trim()
.empty(''),
subject: Joi.string()
.trim()
.empty(''),
attachments: Joi.boolean()
.empty('')
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
flagged: Joi.boolean()
.empty('')
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
searchable: Joi.boolean()
.empty('')
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, '']),
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('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : false;
let query = result.value.query;
let datestart = result.value.datestart || false;
let dateend = result.value.dateend || false;
let filterFrom = result.value.from;
let filterTo = result.value.to;
let filterSubject = result.value.subject;
let filterAttachments = result.value.attachments;
let filterFlagged = result.value.flagged;
let filterSearchable = result.value.searchable;
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrevious = result.value.previous;
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
username: true,
address: true,
specialUse: 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 mailboxNeeded = false;
// NB! Scattered query, searches over all user mailboxes and all shards
let filter = {
user
};
if (query) {
filter.searchable = true;
filter.$text = { $search: query, $language: 'none' };
}
if (mailbox) {
filter.mailbox = mailbox;
}
if (filterFlagged) {
// mailbox is not needed as there's a special index for flagged messages
filter.flagged = true;
}
if (filterSearchable) {
filter.searchable = true;
}
if (datestart) {
if (!filter.idate) {
filter.idate = {};
}
filter.idate.$gte = datestart;
mailboxNeeded = true;
}
if (dateend) {
if (!filter.idate) {
filter.idate = {};
}
filter.idate.$lte = dateend;
mailboxNeeded = true;
}
if (filterFrom) {
let regex = filterFrom.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) {
filter.$and = [];
}
filter.$and.push({
headers: {
$elemMatch: {
key: 'from',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
mailboxNeeded = true;
}
if (filterTo) {
let regex = filterTo.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) {
filter.$and = [];
}
filter.$and.push({
$or: [
{
headers: {
$elemMatch: {
key: 'to',
value: {
$regex: regex,
$options: 'i'
}
}
}
},
{
headers: {
$elemMatch: {
key: 'cc',
value: {
$regex: regex,
$options: 'i'
}
}
}
}
]
});
mailboxNeeded = true;
}
if (filterSubject) {
let regex = filterSubject.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
if (!filter.$and) {
filter.$and = [];
}
filter.$and.push({
headers: {
$elemMatch: {
key: 'subject',
value: {
$regex: regex,
$options: 'i'
}
}
}
});
mailboxNeeded = true;
}
if (filterAttachments) {
filter.ha = true;
mailboxNeeded = true;
}
if (!mailbox && mailboxNeeded) {
// generate a list of mailbox ID values
let mailboxes;
try {
mailboxes = await db.database
.collection('mailboxes')
.find({ user })
.project({
_id: true
})
.toArray();
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
filter.mailbox = { $in: mailboxes.map(m => m._id) };
}
let total = await util.promisify(getFilteredMessageCount)(db, filter);
let opts = {
limit,
query: filter,
fields: {
// FIXME: hack to keep _id in response
_id: true,
// FIXME: MongoPaging inserts fields value as second argument to col.find()
projection: {
_id: true,
uid: true,
msgid: true,
mailbox: true,
'meta.from': true,
hdate: true,
subject: true,
'mimeTree.parsedHeader.from': true,
'mimeTree.parsedHeader.sender': true,
'mimeTree.parsedHeader.to': true,
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
thread: true,
flags: true
}
},
paginatedField: '_id',
sortAscending: false
};
if (pageNext) {
opts.next = pageNext;
} else if (page > 1 && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.database.collection('messages'), 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(formatMessageListing)
};
res.json(response);
return next();
})
);
/**
* @api {get} /users/:user/mailboxes/:mailbox/messages/:message Request Message information
* @apiName GetMessage
* @apiGroup Messages
* @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} mailbox ID of the Mailbox
* @apiParam {Number} message ID of the Message
* @apiParam {Boolean} [markAsSeen=false] If true then marks message as seen
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Number} id ID of the Message
* @apiSuccess {String} mailbox ID of the Mailbox
* @apiSuccess {String} user ID of the User
* @apiSuccess {Object} envelope SMTP envelope (if available)
* @apiSuccess {String} envelope.from Address from MAIL FROM
* @apiSuccess {Object[]} envelope.rcpt Array of addresses from RCPT TO (should have just one normally)
* @apiSuccess {String} envelope.rcpt.value RCPT TO address as provided by SMTP client
* @apiSuccess {String} envelope.rcpt.formatted Normalized RCPT address
* @apiSuccess {Object} from From: header info
* @apiSuccess {String} from.name Name of the sender
* @apiSuccess {String} from.address Address of the sender
* @apiSuccess {Object[]} to To: header info
* @apiSuccess {String} to.name Name of the recipient
* @apiSuccess {String} to.address Address of the recipient
* @apiSuccess {Object[]} cc Cc: header info
* @apiSuccess {String} cc.name Name of the recipient
* @apiSuccess {String} cc.address Address of the recipient
* @apiSuccess {String} subject Message subject
* @apiSuccess {String} messageId Message-ID header
* @apiSuccess {String} date Datestring of message header
* @apiSuccess {Object} list If set then this message is from a mailing list
* @apiSuccess {String} list.id Value from List-ID header
* @apiSuccess {String} list.unsubscribe Value from List-Unsubscribe header
* @apiSuccess {String} expires Datestring, if set then indicates the time after this message is automatically deleted
* @apiSuccess {Boolean} seen Does this message have a \Seen flag
* @apiSuccess {Boolean} deleted Does this message have a \Deleted flag
* @apiSuccess {Boolean} flagged Does this message have a \Flagged flag
* @apiSuccess {Boolean} draft Does this message have a \Draft flag
* @apiSuccess {String[]} html An array of HTML string. Every array element is from a separate mime node, usually you would just join these to a single string
* @apiSuccess {String} text Plaintext content of the message
* @apiSuccess {Object[]} attachments List of attachments for this message
* @apiSuccess {String} attachments.id Attachment ID
* @apiSuccess {String} attachments.filename Filename of the attachment
* @apiSuccess {String} attachments.contentType MIME type
* @apiSuccess {String} attachments.disposition Attachment disposition
* @apiSuccess {String} attachments.transferEncoding Which transfer encoding was used (actual content when fetching attachments is not encoded)
* @apiSuccess {Boolean} attachments.related Was this attachment found from a multipart/related node. This usually means that this is an embedded image
* @apiSuccess {Number} attachments.sizeKb Approximate size of the attachment in kilobytes
* @apiSuccess {Object} contentType Parsed Content-Type header. Usually needed to identify encrypted messages and such
* @apiSuccess {String} contentType.value MIME type of the message, eg. "multipart/mixed"
* @apiSuccess {Object} contentType.params An object with Content-Type params as key-value pairs
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": 1,
* "mailbox": "59fc66a03e54454869460e46",
* "user": "59fc66a03e54454869460e45",
* "from": {
* "address": "rfinnie@domain.dom",
* "name": "Ryan Finnie"
* },
* "to": [
* {
* "address": "bob@domain.dom",
* "name": ""
* }
* ],
* "subject": "Ryan Finnie's MIME Torture Test v1.0",
* "messageId": "<1066976914.4721.5.camel@localhost>",
* "date": "2003-10-24T06:28:34.000Z",
* "seen": true,
* "deleted": false,
* "flagged": true,
* "draft": false,
* "html": [
* "<p>Welcome to Ryan Finnie&apos;s MIME torture test.</p>",
* "<p>While a message/rfc822 part inside another message/rfc822 part in a<br/>message isn&apos;t too strange, 200 iterations of that would be.</p>"
* ],
* "text": "Welcome to Ryan Finnie's MIME torture test. This message was designed\nto introduce a couple of the newer features of MIME-aware MUA",
* "attachments": [
* {
* "id": "ATT00004",
* "filename": "foo.gz",
* "contentType": "application/x-gzip",
* "disposition": "attachment",
* "transferEncoding": "base64",
* "related": false,
* "sizeKb": 1
* },
* {
* "id": "ATT00007",
* "filename": "blah1.gz",
* "contentType": "application/x-gzip",
* "disposition": "attachment",
* "transferEncoding": "base64",
* "related": false,
* "sizeKb": 1
* }
* ],
* "contentType": {
* "value": "multipart/mixed",
* "params": {
* "boundary": "=-qYxqvD9rbH0PNeExagh1"
* }
* }
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'message', path: '/users/:user/mailboxes/:mailbox/messages/:message' },
tools.asyncifyJson(async (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', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
markAsSeen: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
if (req.query.replaceCidLinks) {
req.params.replaceCidLinks = req.query.replaceCidLinks;
}
if (req.query.markAsSeen) {
req.params.markAsSeen = req.query.markAsSeen;
}
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('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let replaceCidLinks = result.value.replaceCidLinks;
let messageData;
try {
messageData = await db.database.collection('messages').findOne(
{
mailbox,
uid: message
},
{
projection: {
_id: true,
user: true,
thread: true,
hdate: true,
'mimeTree.parsedHeader': true,
subject: true,
msgid: true,
exp: true,
rdate: true,
ha: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
flags: true,
attachments: true,
html: true,
text: true,
textFooter: true,
forwardTargets: true,
meta: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let parsedHeader = (messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let envelope = {
from: (messageData.meta && messageData.meta.from) || '',
rcpt: []
.concat((messageData.meta && messageData.meta.to) || [])
.map(rcpt => rcpt && rcpt.trim())
.filter(rcpt => rcpt)
.map(rcpt => ({
value: rcpt,
formatted: tools.normalizeAddress(rcpt, false, { removeLabel: true, removeDots: true })
}))
};
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();
}
messageData.text = (messageData.text || '') + (messageData.textFooter || '');
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 })
)
);
messageData.text = messageData.text.replace(/attachment:([a-f0-9]+)\/(ATT\d+)/g, (str, mid, aid) =>
server.router.render('attachment', { user, mailbox, message, attachment: aid })
);
}
if (result.value.markAsSeen && messageData.unseen) {
// we need to mark this message as seen
try {
await updateMessage(user, mailbox, message, { seen: true });
} catch (err) {
res.json({
error: err.message
});
return next();
}
messageData.unseen = false;
}
let response = {
success: true,
id: message,
mailbox,
user,
envelope,
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,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded'),
html: messageData.html,
text: messageData.text,
forwardTargets: messageData.forwardTargets,
attachments: messageData.attachments || [],
meta: messageData.meta || {},
references: (parsedHeader.references || '')
.toString()
.split(/\s+/)
.filter(ref => ref)
};
let parsedContentType = parsedHeader['content-type'];
if (parsedContentType) {
response.contentType = {
value: parsedContentType.value
};
if (parsedContentType.hasParams) {
response.contentType.params = parsedContentType.params;
}
if (parsedContentType.subtype === 'encrypted') {
response.encrypted = true;
}
}
res.json(response);
return next();
})
);
/**
* @api {get} /users/:user/mailboxes/:mailbox/messages/:message/events Message events
* @apiName GetMessageEvents
* @apiGroup Messages
* @apiDescription This method returns a listing of events related to this messages. This includes how the message was received and also information about forwarding
* @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} mailbox ID of the Mailbox
* @apiParam {Number} message ID of the Message
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Object[]} events List of events
* @apiSuccess {String} action Event type
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1/events"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "59fc66a03e54454869460e4e",
* "events": [
* {
* "id": "59fc66a03e54454869460e4e",
* "stored": "59fc66a03e54454869460e4e",
* "action": "STORE",
* "origin": "Import",
* "messageId": "<1066976914.4721.5.camel@localhost>",
* "from": null,
* "to": [
* "user1@example.com"
* ],
* "transtype": null,
* "time": 1509713568834
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'messageevents', path: '/users/:user/mailboxes/:mailbox/messages/:message/events' },
tools.asyncifyJson(async (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(),
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).readOwn('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let messageData;
try {
messageData = await db.database.collection('messages').findOne(
{
mailbox,
uid: message
},
{
projection: {
_id: true,
msgid: true,
user: true,
mailbox: true,
uid: true,
meta: true,
outbound: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let logEntries;
let logQuery = false;
if (messageData.outbound && messageData.outbound.length === 1) {
logQuery = {
$or: [{ id: messageData.outbound[0] }, { parentId: messageData._id }]
};
} else if (messageData.outbound && messageData.outbound.length > 1) {
logQuery = {
$or: [{ id: { $in: messageData.outbound } }, { parentId: messageData._id }]
};
} else {
logQuery = {
parentId: messageData._id
};
}
if (logQuery) {
try {
logEntries = await db.database
.collection('messagelog')
.find(logQuery)
.sort({ _id: 1 })
.toArray();
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
}
let response = {
success: true,
id: messageData._id,
events: []
.concat(
(logEntries || []).map(entry => ({
id: entry.id,
seq: entry.seq,
stored: entry.parentId,
action: entry.action,
origin: entry.origin || entry.source,
src: entry.ip,
dst: entry.host,
mx: entry.mx,
targets: entry.targets,
reason: entry.reason,
error: entry.error,
response: entry.response,
messageId: entry['message-id'],
from: entry.from,
to: entry.to && [].concat(typeof entry.to === 'string' ? entry.to.trim().split(/\s*,\s*/) : entry.to || []),
transtype: entry.transtype,
time: entry.created
}))
)
.sort((a, b) => a.time - b.time)
};
res.json(response);
return next();
})
);
/**
* @api {get} /users/:user/mailboxes/:mailbox/messages/:message/message.eml Get Message source
* @apiName GetMessageSource
* @apiGroup Messages
* @apiDescription This method returns the full RFC822 formatted source of the stored message
* @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} mailbox ID of the Mailbox
* @apiParam {Number} message ID of the Message
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1/message.eml"
*
* @apiSuccessExample {text} Success-Response:
* HTTP/1.1 200 OK
* Content-Type: message/rfc822
*
* Subject: Ryan Finnie's MIME Torture Test v1.0
* From: Ryan Finnie <rfinnie@domain.dom>
* To: bob@domain.dom
* Content-Type: multipart/mixed; boundary="=-qYxqvD9rbH0PNeExagh1"
* ...
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'raw', path: '/users/:user/mailboxes/:mailbox/messages/:message/message.eml' },
tools.asyncifyJson(async (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(),
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).readOwn('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let messageData;
try {
messageData = await db.database.collection('messages').findOne(
{
mailbox,
uid: message
},
{
projection: {
_id: true,
user: true,
mimeTree: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let response = messageHandler.indexer.rebuild(messageData.mimeTree);
if (!response || response.type !== 'stream' || !response.value) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
res.setHeader('Content-Type', 'message/rfc822');
response.value.on('error', err => {
log.error('API', 'message=%s error=%s', messageData._id, err.message);
try {
res.end();
} catch (err) {
//ignore
}
});
response.value.pipe(res);
})
);
/**
* @api {get} /users/:user/mailboxes/:mailbox/messages/:message/attachments/:attachment Download Attachment
* @apiName GetMessageAttachment
* @apiGroup Messages
* @apiDescription This method returns attachment 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} mailbox ID of the Mailbox
* @apiParam {Number} message ID of the Message
* @apiParam {String} attachment ID of the Attachment
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a13e54454869460e57/messages/1/attachments/ATT00002"
*
* @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: 'attachment', path: '/users/:user/mailboxes/:mailbox/messages/:message/attachments/:attachment' },
tools.asyncifyJson(async (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,
code: 'InputValidationError'
});
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let attachment = result.value.attachment;
let messageData;
try {
messageData = await db.database.collection('messages').findOne(
{
mailbox,
uid: message,
user
},
{
projection: {
_id: true,
user: true,
attachments: true,
'mimeTree.attachmentMap': true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let attachmentId = messageData.mimeTree.attachmentMap && messageData.mimeTree.attachmentMap[attachment];
if (!attachmentId) {
res.json({
error: 'This attachment does not exist'
});
return next();
}
let attachmentData;
try {
attachmentData = await getAttachmentData(attachmentId);
} catch (err) {
res.json({
error: err.message
});
return next();
}
res.writeHead(200, {
'Content-Type': attachmentData.contentType || 'application/octet-stream'
});
let decode = true;
if (attachmentData.metadata.decoded) {
attachmentData.metadata.decoded = false;
decode = false;
}
let attachmentStream = messageHandler.attachmentStorage.createReadStream(attachmentId, attachmentData);
attachmentStream.once('error', err => {
log.error('API', 'message=%s attachment=%s error=%s', messageData._id, attachmentId, err.message);
try {
res.end();
} catch (err) {
//ignore
}
});
if (!decode) {
attachmentStream.pipe(res);
return;
}
if (attachmentData.transferEncoding === 'base64') {
attachmentStream.pipe(new libbase64.Decoder()).pipe(res);
} else if (attachmentData.transferEncoding === 'quoted-printable') {
attachmentStream.pipe(new libqp.Decoder()).pipe(res);
} else {
attachmentStream.pipe(res);
}
})
);
/**
* @api {put} /users/:user/mailboxes/:mailbox/messages Update Message information
* @apiName PutMessage
* @apiGroup Messages
* @apiDescription This method updates message flags and also allows to move messages to a different mailbox
* @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} mailbox ID of the Mailbox
* @apiParam {String} message Message ID values. Either comma separated numbers (1,2,3) or colon separated range (3:15)
* @apiParam {String} moveTo ID of the target Mailbox if you want to move messages
* @apiParam {Boolean} seen State of the \Seen flag
* @apiParam {Boolean} flagged State of the \Flagged flag
* @apiParam {Boolean} draft State of the \Draft flag
* @apiParam {Datestring} expires Either expiration date or <code>false</code> to turn of autoexpiration
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Object[]} id If messages were moved then lists new ID values. Array entry is an array with first element pointing to old ID and second to new ID
* @apiSuccess {Number} updated If messages were not moved, then indicates the number of updated messages
*
* @apiError error Description of the error
*
* @apiExample {curl} Mark messages as unseen:
* curl -i -XPUT "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages" \
* -H 'Content-type: application/json' \
* -d '{
* "message": "1,2,3",
* "seen": false
* }'
*
* @apiSuccessExample {json} Update Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "updated": 2
* }
*
* @apiSuccessExample {json} Move Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "mailbox": "59fc66a13e54454869460e57",
* "id": [
* [1,24],
* [2,25]
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.put('/users/:user/mailboxes/:mailbox/messages/:message', tools.asyncifyJson(putMessageHandler));
server.put('/users/:user/mailboxes/:mailbox/messages', tools.asyncifyJson(putMessageHandler));
/**
* @api {delete} /users/:user/mailboxes/:mailbox/messages/:message Delete a Message
* @apiName DeleteMessage
* @apiGroup Messages
* @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} mailbox ID of the Mailbox
* @apiParam {Number} message Message ID
*
* @apiSuccess {Boolean} success Indicates successful response
*
* @apiError error Description of the error
*
* @apiExample {curl} Delete a Message:
* curl -i -XDELETE "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a13e54454869460e57/messages/2"
*
* @apiSuccessExample {json} Delete Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.del(
'/users/:user/mailboxes/:mailbox/messages/:message',
tools.asyncifyJson(async (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(),
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).deleteOwn('messages'));
} else {
req.validate(roles.can(req.role).deleteAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
let messageData;
try {
messageData = await db.database.collection('messages').findOne({
mailbox,
uid: message
});
} catch (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();
}
try {
await deleteMessage({
user,
mailbox: { user, mailbox },
messageData,
archive: !messageData.flags.includes('\\Draft')
});
} catch (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success: true
});
return next();
})
);
/**
* @api {post} /users/:user/mailboxes/:mailbox/messages Upload Message Source
* @apiName UploadMessage
* @apiGroup Messages
* @apiDescription This method allows to upload an RFC822 formatted message to a mailbox. Message
* is stored unmodified, no headers are added or removed. If you want to generate the uploaded message
* from strucutred data fields, then see <a href="#api-Submission-PostSubmit">Submit a Message for Delivery</a>
* with <code>uploadOnly</code> option
* @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} mailbox ID of the Mailbox
* @apiParam {Boolean} [unseen=false] Is the message unseen or not
* @apiParam {Boolean} [draft=false] Is the message a draft or not
* @apiParam {Boolean} [flagged=false] Is the message flagged or not
* @apiParam {String} raw base64 encoded message source. Alternatively, you can provide this value as POST body by using message/rfc822 MIME type
* @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {Object} message Message information
* @apiSuccess {Number} message.id Message ID in mailbox
* @apiSuccess {String} message.mailbox Mailbox ID the message was stored into
*
* @apiError error Description of the error
*
* @apiExample {curl} Delete a Message:
* curl -i -XPOST "http://localhost:8080/users/5a2f9ca57308fc3a6f5f811d/mailboxes/5a2f9ca57308fc3a6f5f811e/messages" \
* -H 'Content-type: message/rfc822' \
* -d 'From: sender@example.com
* To: recipient@example.com
* Subject: hello world!
*
* Example message'
*
* @apiSuccessExample {json} Forward Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "message": {
* "id": 2,
* "mailbox": "5a2f9ca57308fc3a6f5f811e"
* }
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.post(
'/users/:user/mailboxes/:mailbox/messages',
tools.asyncifyJson(async (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(),
date: Joi.date(),
unseen: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
flagged: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
draft: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
raw: Joi.binary()
.max(consts.MAX_ALLOWE_MESSAGE_SIZE)
.required(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
Object.keys(req.query || {}).forEach(key => {
if (!(key in req.params)) {
req.params[key] = req.query[key];
}
});
req.params.raw = req.params.raw || req.body;
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('messages'));
} else {
req.validate(roles.can(req.role).createAny('messages'));
}
let user = new ObjectID(result.value.user);
let mailbox = new ObjectID(result.value.mailbox);
let raw = result.value.raw;
let date = result.value.date || new Date();
let mailboxData;
try {
mailboxData = await db.database.collection('mailboxes').findOne({
_id: mailbox,
user
});
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!mailboxData) {
res.json({
error: 'This mailbox does not exist'
});
return next();
}
let userData;
try {
userData = await db.users.collection('users').findOne({
_id: user
});
} 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();
}
if (userData.quota && userData.storageUsed > userData.quota) {
res.json({
error: 'User is over quota',
code: 'OVERQUOTA'
});
return next();
}
if (userData.encryptMessages) {
try {
let encrypted = await encryptMessage(userData.pubKey, raw);
if (encrypted) {
raw = encrypted;
}
} catch (err) {
// ignore
}
}
let status, data;
try {
let resp = await addMessage({
user,
mailbox: mailboxData,
meta: {
source: 'API',
from: '',
origin: result.value.ip || '127.0.0.1',
transtype: 'UPLOAD',
time: date
},
session: result.value.session,
date,
flags: []
.concat('unseen' in result.value ? (result.value.unseen ? [] : '\\Seen') : [])
.concat('flagged' in result.value ? (result.value.flagged ? '\\Flagged' : []) : [])
.concat('draft' in result.value ? (result.value.draft ? '\\Draft' : []) : []),
raw
});
status = resp.status;
data = resp.data;
} catch (err) {
res.json({
error: err.message,
code: err.imapResponse
});
return next();
}
res.json({
success: status,
message: data
? {
id: data.uid,
mailbox: data.mailbox
}
: false
});
return next();
})
);
/**
* @api {post} /users/:user/mailboxes/:mailbox/messages/:message/forward Forward stored Message
* @apiName DeleteMessage
* @apiGroup Messages
* @apiDescription This method allows either to re-forward a message to an original forward target
* or forward it to some other address. This is useful if an user had forwarding turned on but the
* message was not delivered so you can try again. Forwarding does not modify the original message.
* @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} mailbox ID of the Mailbox
* @apiParam {Number} message Message ID
* @apiParam {Number} [target] Number of original forwarding target
* @apiParam {String[]} [addresses] An array of additional forward targets
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} queueId Message ID in outbound queue
* @apiSuccess {Object[]} forwarded Information about forwarding targets
* @apiSuccess {String} forwarded.seq Sequence ID
* @apiSuccess {String} forwarded.type Target type
* @apiSuccess {String} forwarded.value Target address
*
* @apiError error Description of the error
*
* @apiExample {curl} Delete a Message:
* curl -i -XPOST "http://localhost:8080/users/59fc66a03e54454869460e45/mailboxes/59fc66a13e54454869460e57/messages/1/forward" \
* -H 'Content-type: application/json' \
* -d '{
* "addresses": [
* "andris@ethereal.email"
* ]
* }'
*
* @apiSuccessExample {json} Forward Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "1600d2f36470008b72",
* "forwarded": [
* {
* "seq": "001",
* "type": "mail",
* "value": "andris@ethereal.email"
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.post('/users/:user/mailboxes/:mailbox/messages/:message/forward', (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().required(),
target: Joi.number()
.min(1)
.max(1000),
addresses: Joi.array().items(Joi.string().email()),
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);
let mailbox = new ObjectID(result.value.mailbox);
let message = result.value.message;
db.database.collection('messages').findOne(
{
mailbox,
uid: message
},
{
projection: {
_id: true,
mailbox: true,
user: true,
uid: true,
'meta.from': true,
'meta.to': true,
mimeTree: true,
forwardTargets: true
}
},
(err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let forwardTargets = [];
[].concat(result.value.addresses || []).forEach(address => {
forwardTargets.push({ type: 'mail', value: address });
});
if (messageData.forwardTargets) {
if (result.value.target) {
forwardTargets = forwardTargets.concat(messageData.forwardTargets[result.value.target - 1] || []);
} else if (!forwardTargets.length) {
forwardTargets = messageData.forwardTargets;
}
}
if (!forwardTargets || !forwardTargets.length) {
res.json({
success: true,
forwarded: []
});
return next();
}
let response = messageHandler.indexer.rebuild(messageData.mimeTree);
if (!response || response.type !== 'stream' || !response.value) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let forwardData = {
db,
maildrop,
parentId: messageData._id,
sender: messageData.meta.from,
recipient: messageData.meta.to,
targets: forwardTargets,
stream: response.value
};
forward(forwardData, (err, queueId) => {
if (err) {
log.error(
'API',
'%s FRWRDFAIL from=%s to=%s target=%s error=%s',
forwardData.parentId.toString(),
forwardData.sender,
forwardData.recipient,
forwardTargets.map(target => (typeof target.value === 'string' ? target.value : 'relay')).join(','),
err.message
);
} else if (queueId) {
log.silly(
'API',
'%s FRWRDOK id=%s from=%s to=%s target=%s',
forwardData.parentId.toString(),
queueId,
forwardData.sender,
forwardData.recipient,
forwardTargets.map(target => (typeof target.value === 'string' ? target.value : 'relay')).join(',')
);
}
return db.database.collection('messages').findOneAndUpdate(
{
_id: messageData._id,
mailbox: messageData.mailbox,
uid: messageData.uid
},
{
$addToSet: {
outbound: queueId
}
},
{
returnOriginal: true,
projection: {
_id: true,
outbound: true
}
},
() => {
res.json({
success: true,
queueId,
forwarded: forwardTargets.map((target, i) => ({
seq: leftPad((i + 1).toString(16), '0', 3),
type: target.type,
value: target.value
}))
});
return next();
}
);
});
}
);
});
/**
* @api {get} /users/:user/archived List archived messages
* @apiName GetArchivedMessages
* @apiGroup Archive
* @apiDescription Archive contains all recently deleted messages besides Drafts etc.
* @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 {Number} [limit=20] How many records to return
* @apiParam {Number} [page=1] Current page number. Informational only, page numbers start from 1
* @apiParam {Number} [order="desc"] Ordering of the records by insert date
* @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 Message listing
* @apiSuccess {String} results.id ID of the Message (24 byte hex)
* @apiSuccess {String} results.mailbox ID of the Mailbox
* @apiSuccess {String} results.thread ID of the Thread
* @apiSuccess {Object} results.from Sender info
* @apiSuccess {String} results.from.name Name of the sender
* @apiSuccess {String} results.from.address Address of the sender
* @apiSuccess {Object[]} results.to Recipients in To: field
* @apiSuccess {String} results.to.name Name of the recipient
* @apiSuccess {String} results.to.address Address of the recipient
* @apiSuccess {Object[]} results.cc Recipients in Cc: field
* @apiSuccess {String} results.cc.name Name of the recipient
* @apiSuccess {String} results.cc.address Address of the recipient
* @apiSuccess {String} results.subject Message subject
* @apiSuccess {String} results.date Datestring
* @apiSuccess {String} results.intro First 128 bytes of the message
* @apiSuccess {Boolean} results.attachments Does the message have attachments
* @apiSuccess {Boolean} results.seen Is this message alread seen or not
* @apiSuccess {Boolean} results.deleted Does this message have a \Deleted flag (should not have as messages are automatically deleted once this flag is set)
* @apiSuccess {Boolean} results.flagged Does this message have a \Flagged flag
* @apiSuccess {Object} results.contentType Parsed Content-Type header. Usually needed to identify encrypted messages and such
* @apiSuccess {String} results.contentType.value MIME type of the message, eg. "multipart/mixed"
* @apiSuccess {Object} results.contentType.params An object with Content-Type params as key-value pairs
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/archived"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "total": 1,
* "page": 1,
* "previousCursor": false,
* "nextCursor": false,
* "results": [
* {
* "id": "59fc66a13e54454869460e58",
* "mailbox": "59fc66a03e54454869460e46",
* "thread": "59fc66a13e54454869460e50",
* "from": {
* "address": "rfinnie@domain.dom",
* "name": "Ryan Finnie"
* },
* "subject": "Ryan Finnie's MIME Torture Test v1.0",
* "date": "2003-10-24T06:28:34.000Z",
* "intro": "Welcome to Ryan Finnie's MIME torture test. This message was designed to introduce a couple of the newer features of MIME-aware…",
* "attachments": true,
* "seen": true,
* "deleted": false,
* "flagged": true,
* "draft": false,
* "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1",
* "contentType": {
* "value": "multipart/mixed",
* "params": {
* "boundary": "=-qYxqvD9rbH0PNeExagh1"
* }
* }
* }
* ]
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get(
{ name: 'archived', path: '/users/:user/archived' },
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
limit: Joi.number()
.empty('')
.default(20)
.min(1)
.max(250),
next: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
previous: Joi.string()
.empty('')
.mongoCursor()
.max(1024),
order: Joi.any()
.empty('')
.allow(['asc', 'desc'])
.default('desc'),
page: Joi.number()
.empty('')
.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('messages'));
} else {
req.validate(roles.can(req.role).readAny('messages'));
}
let user = new ObjectID(result.value.user);
let limit = result.value.limit;
let page = result.value.page;
let pageNext = result.value.next;
let pagePrevious = result.value.previous;
let sortAscending = result.value.order === 'asc';
let total = await util.promisify(getArchivedMessageCount)(db, user);
let opts = {
limit,
query: { user },
fields: {
// FIXME: hack to keep _id in response
_id: true,
// FIXME: MongoPaging inserts fields value as second argument to col.find()
projection: {
_id: true,
uid: true,
msgid: true,
mailbox: true,
'meta.from': true,
hdate: true,
subject: true,
'mimeTree.parsedHeader.from': true,
'mimeTree.parsedHeader.sender': true,
'mimeTree.parsedHeader.to': true,
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
undeleted: true,
flagged: true,
draft: true,
thread: true,
flags: true
}
},
paginatedField: '_id',
sortAscending
};
if (pageNext) {
opts.next = pageNext;
} else if (page > 1 && pagePrevious) {
opts.previous = pagePrevious;
}
let listing;
try {
listing = await MongoPaging.find(db.database.collection('archived'), opts);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!listing.hasPrevious) {
page = 1;
}
let response = {
success: true,
total,
page,
previousCursor: listing.hasPrevious ? listing.previous : false,
nextCursor: listing.hasNext ? listing.next : false,
results: (listing.results || [])
.map(m => {
// prepare message for output
m.uid = m._id;
return m;
})
.map(formatMessageListing)
};
res.json(response);
return next();
})
);
/**
* @api {get} /users/:user/archived/:message Request Archived Message
* @apiName GetArchivedMessage
* @apiGroup Archive
* @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 {Number} message ID of the Message
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID of the Message
* @apiSuccess {String} mailbox ID of the Mailbox the messages was deleted from
* @apiSuccess {String} user ID of the User
* @apiSuccess {Object} from From: header info
* @apiSuccess {Object} from.name Name of the sender
* @apiSuccess {Object} from.address Address of the sender
* @apiSuccess {Object[]} to To: header info
* @apiSuccess {Object} to.name Name of the recipient
* @apiSuccess {Object} to.address Address of the recipient
* @apiSuccess {Object[]} cc Cc: header info
* @apiSuccess {Object} cc.name Name of the recipient
* @apiSuccess {Object} cc.address Address of the recipient
* @apiSuccess {String} subject Message subject
* @apiSuccess {String} messageId Message-ID header
* @apiSuccess {String} date Datestring of message header
* @apiSuccess {Object} list If set then this message is from a mailing list
* @apiSuccess {String} list.id Value from List-ID header
* @apiSuccess {String} list.unsubscribe Value from List-Unsubscribe header
* @apiSuccess {String} expires Datestring, if set then indicates the time after this message is automatically deleted
* @apiSuccess {Boolean} seen Does this message have a \Seen flag
* @apiSuccess {Boolean} deleted Does this message have a \Deleted flag
* @apiSuccess {Boolean} flagged Does this message have a \Flagged flag
* @apiSuccess {Boolean} draft Does this message have a \Draft flag
* @apiSuccess {String[]} html An array of HTML string. Every array element is from a separate mime node, usually you would just join these to a single string
* @apiSuccess {String} text Plaintext content of the message
* @apiSuccess {Object[]} attachments List of attachments for this message
* @apiSuccess {String} attachments.id Attachment ID
* @apiSuccess {String} attachments.filename Filename of the attachment
* @apiSuccess {String} attachments.contentType MIME type
* @apiSuccess {String} attachments.disposition Attachment disposition
* @apiSuccess {String} attachments.transferEncoding Which transfer encoding was used (actual content when fetching attachments is not encoded)
* @apiSuccess {Boolean} attachments.related Was this attachment found from a multipart/related node. This usually means that this is an embedded image
* @apiSuccess {Number} attachments.sizeKb Approximate size of the attachment in kilobytes
* @apiSuccess {Object} contentType Parsed Content-Type header. Usually needed to identify encrypted messages and such
* @apiSuccess {String} contentType.value MIME type of the message, eg. "multipart/mixed"
* @apiSuccess {Object} contentType.params An object with Content-Type params as key-value pairs
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/archived/59fc66a13e54454869460e58"
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "id": "59fc66a13e54454869460e58",
* "mailbox": "59fc66a03e54454869460e46",
* "user": "59fc66a03e54454869460e45",
* "from": {
* "address": "rfinnie@domain.dom",
* "name": "Ryan Finnie"
* },
* "to": [
* {
* "address": "bob@domain.dom",
* "name": ""
* }
* ],
* "subject": "Ryan Finnie's MIME Torture Test v1.0",
* "messageId": "<1066976914.4721.5.camel@localhost>",
* "date": "2003-10-24T06:28:34.000Z",
* "seen": true,
* "deleted": false,
* "flagged": true,
* "draft": false,
* "html": [
* "<p>Welcome to Ryan Finnie&apos;s MIME torture test.</p>",
* "<p>While a message/rfc822 part inside another message/rfc822 part in a<br/>message isn&apos;t too strange, 200 iterations of that would be.</p>"
* ],
* "text": "Welcome to Ryan Finnie's MIME torture test. This message was designed\nto introduce a couple of the newer features of MIME-aware MUA",
* "attachments": [
* {
* "id": "ATT00004",
* "filename": "foo.gz",
* "contentType": "application/x-gzip",
* "disposition": "attachment",
* "transferEncoding": "base64",
* "related": false,
* "sizeKb": 1
* },
* {
* "id": "ATT00007",
* "filename": "blah1.gz",
* "contentType": "application/x-gzip",
* "disposition": "attachment",
* "transferEncoding": "base64",
* "related": false,
* "sizeKb": 1
* }
* ],
* "contentType": {
* "value": "multipart/mixed",
* "params": {
* "boundary": "=-qYxqvD9rbH0PNeExagh1"
* }
* }
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.get({ name: 'archived_message', path: '/users/:user/archived/:message' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
message: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
replaceCidLinks: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', '1', 1])
.falsy(['N', 'false', 'no', 'off', '0', 0, ''])
.default(false),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
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,
code: 'InputValidationError'
});
return next();
}
let user = new ObjectID(result.value.user);
let message = new ObjectID(result.value.message);
let replaceCidLinks = result.value.replaceCidLinks;
db.database.collection('archived').findOne(
{
_id: message,
user
},
{
projection: {
_id: true,
mailbox: 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,
flags: true,
attachments: true,
html: true,
text: true,
textFooter: true,
forwardTargets: true
}
},
(err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
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();
}
messageData.text = (messageData.text || '') + (messageData.textFooter || '');
if (replaceCidLinks) {
messageData.html = (messageData.html || []).map(html =>
html.replace(/attachment:([a-f0-9]+)\/(ATT\d+)/g, (str, mid, aid) =>
server.router.render('archived_attachment', { user, message, attachment: aid })
)
);
messageData.text = messageData.text.replace(/attachment:([a-f0-9]+)\/(ATT\d+)/g, (str, mid, aid) =>
server.router.render('archived_attachment', { user, message, attachment: aid })
);
}
let response = {
success: true,
id: message,
mailbox: messageData.mailbox,
user,
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,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded'),
html: messageData.html,
text: messageData.text,
forwardTargets: messageData.forwardTargets,
attachments: messageData.attachments || []
};
let parsedContentType = parsedHeader['content-type'];
if (parsedContentType) {
response.contentType = {
value: parsedContentType.value
};
if (parsedContentType.hasParams) {
response.contentType.params = parsedContentType.params;
}
if (parsedContentType.subtype === 'encrypted') {
response.encrypted = true;
}
}
res.json(response);
return next();
}
);
});
/**
* @api {get} /users/:user/archived/:message/attachments/:attachment Download Archived Attachment
* @apiName GetArchivedAttachment
* @apiGroup Archive
* @apiDescription This method returns attachment 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 {Number} message ID of the Archived Message
* @apiParam {String} attachment ID of the Attachment
*
* @apiError error Description of the error
*
* @apiExample {curl} Example usage:
* curl -i "http://localhost:8080/users/59fc66a03e54454869460e45/archived/59fc66a13e54454869460e58/attachments/ATT00003"
*
* @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: 'archived_attachment', path: '/users/:user/archived/:message/attachments/:attachment' }, (req, res, next) => {
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
message: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
attachment: Joi.string()
.regex(/^ATT\d+$/i)
.uppercase()
.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);
let message = new ObjectID(result.value.message);
let attachment = result.value.attachment;
db.database.collection('archived').findOne(
{
user,
_id: message
},
{
projection: {
_id: true,
user: true,
attachments: true,
'mimeTree.attachmentMap': true
}
},
(err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
let attachmentId = messageData.mimeTree.attachmentMap && messageData.mimeTree.attachmentMap[attachment];
if (!attachmentId) {
res.json({
error: 'This attachment does not exist'
});
return next();
}
messageHandler.attachmentStorage.get(attachmentId, (err, attachmentData) => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.writeHead(200, {
'Content-Type': attachmentData.contentType || 'application/octet-stream'
});
let decode = true;
if (attachmentData.metadata.decoded) {
attachmentData.metadata.decoded = false;
decode = false;
}
let attachmentStream = messageHandler.attachmentStorage.createReadStream(attachmentId, attachmentData);
attachmentStream.once('error', err => res.emit('error', err));
if (!decode) {
return attachmentStream.pipe(res);
}
if (attachmentData.transferEncoding === 'base64') {
attachmentStream.pipe(new libbase64.Decoder()).pipe(res);
} else if (attachmentData.transferEncoding === 'quoted-printable') {
attachmentStream.pipe(new libqp.Decoder()).pipe(res);
} else {
attachmentStream.pipe(res);
}
});
}
);
});
/**
* @api {post} /users/:user/archived/:message/restore Restore archived Message
* @apiName RestoreMessage
* @apiGroup Archive
* @apiDescription Restores an archived message by moving it back to the mailbox it
* was deleted from or to provided target mailbox. If target mailbox does not exist, then
* the message is moved to INBOX.
* @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 {Number} message Message ID
* @apiParam {String} [mailbox] ID of the target Mailbox. If not set then original mailbox is used.
*
* @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} mailbox Maibox ID the message was moved to
* @apiSuccess {Number} id New ID for the Message
*
* @apiError error Description of the error
*
* @apiExample {curl} Restore a Message:
* curl -i -XPOST "http://localhost:8080/users/59fc66a03e54454869460e45/archived/59fc66a13e54454869460e58/restore" \
* -H 'Content-type: application/json' \
* -d '{}'
*
* @apiSuccessExample {json} Restore Response:
* HTTP/1.1 200 OK
* {
* "success": true,
* "mailbox": "59fc66a13e54454869460e57",
* "id": 4
* }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "Database error"
* }
*/
server.post({ name: 'archived_restore', path: '/users/:user/archived/:message/restore' }, (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
message: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
mailbox: Joi.string()
.hex()
.lowercase()
.length(24),
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);
let message = new ObjectID(result.value.message);
let mailbox = result.value.mailbox ? new ObjectID(result.value.mailbox) : false;
db.database.collection('archived').findOne(
{
_id: message,
user
},
(err, messageData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!messageData || messageData.user.toString() !== user.toString()) {
res.json({
error: 'This message does not exist',
code: 'NoSuchMessage'
});
return next();
}
messageData.mailbox = mailbox || messageData.mailbox;
delete messageData.archived;
delete messageData.exp;
delete messageData.rdate;
messageHandler.put(messageData, (err, response) => {
if (err) {
res.json({
error: err.message
});
} else if (!response) {
res.json({
succese: false,
error: 'Failed to restore message'
});
} else {
response.success = true;
res.json({
success: true,
mailbox: response.mailbox,
id: response.uid
});
return db.database.collection('archived').deleteOne({ _id: messageData._id }, () => next());
}
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').countDocuments(filter, (err, total) => {
if (err) {
return done(err);
}
done(null, total);
});
}
function getArchivedMessageCount(db, user, done) {
db.database.collection('archived').countDocuments({ user }, (err, total) => {
if (err) {
return done(err);
}
done(null, total);
});
}
function leftPad(val, chr, len) {
return chr.repeat(len - val.toString().length) + val;
}
function formatMessageListing(messageData) {
let parsedHeader = (messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let from = parsedHeader.from ||
parsedHeader.sender || [
{
name: '',
address: (messageData.meta && messageData.meta.from) || ''
}
];
let to = [].concat(parsedHeader.to || []);
let cc = [].concat(parsedHeader.cc || []);
tools.decodeAddresses(from);
tools.decodeAddresses(to);
tools.decodeAddresses(cc);
let response = {
id: messageData.uid,
mailbox: messageData.mailbox,
thread: messageData.thread,
from: from && from[0],
to,
cc,
messageId: messageData.msgid,
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,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded'),
references: (parsedHeader.references || '')
.toString()
.split(/\s+/)
.filter(ref => ref)
};
let parsedContentType = parsedHeader['content-type'];
if (parsedContentType) {
response.contentType = {
value: parsedContentType.value
};
if (parsedContentType.hasParams) {
response.contentType.params = parsedContentType.params;
}
if (parsedContentType.subtype === 'encrypted') {
response.encrypted = true;
}
}
return response;
}