use targets

This commit is contained in:
Andris Reinman 2018-01-20 21:38:56 +02:00
parent 198f487169
commit 127536799f
15 changed files with 739 additions and 523 deletions

View file

@ -1,5 +1,7 @@
# HTTP API # HTTP API
**DEPRECATED DOCS**, see https://api.wildduck.email
WildDuck Mail Server is a scalable IMAP / POP3 server that natively exposes internal data through an HTTP API. WildDuck Mail Server is a scalable IMAP / POP3 server that natively exposes internal data through an HTTP API.
This API is not meant to be used by end users but your application. This API is not meant to be used by end users but your application.
@ -223,8 +225,6 @@ Response for a successful operation:
"name": null, "name": null,
"address": "testuser01@example.com", "address": "testuser01@example.com",
"tags": ["green", "blue"], "tags": ["green", "blue"],
"forward": [],
"targetUrl": "",
"encryptMessages": false, "encryptMessages": false,
"encryptForwarded": false, "encryptForwarded": false,
"quota": { "quota": {
@ -270,8 +270,6 @@ Response for a successful operation:
"encryptForwarded": false, "encryptForwarded": false,
"pubKey": "", "pubKey": "",
"keyInfo": false, "keyInfo": false,
"forward": [],
"targetUrl": "",
"limits": { "limits": {
"quota": { "quota": {
"allowed": 107374182400, "allowed": 107374182400,
@ -311,8 +309,6 @@ Creates a new user, returns the ID upon success.
* **address** is the main email address for the user. If address is not set then a new one is generated based on the username and current domain name * **address** is the main email address for the user. If address is not set then a new one is generated based on the username and current domain name
* **emptyAddress** if true, then do not set up an address for the user * **emptyAddress** if true, then do not set up an address for the user
* **name** is the name for the user * **name** is the name for the user
* **forward** is an array of email addresses to where all messages are forwarded to
* **targetUrl** is an URL to where all messages are uploaded to
* **quota** is the maximum storage in bytes allowed for this user. If not set then the default value is used * **quota** is the maximum storage in bytes allowed for this user. If not set then the default value is used
* **retention** is the default retention time in ms for mailboxes. Messages in Trash and Junk folders have a capped retention time of 30 days. * **retention** is the default retention time in ms for mailboxes. Messages in Trash and Junk folders have a capped retention time of 30 days.
* **language** is the language code for the user, eg. "en" or "et". Mailbox names for the default mailboxes (eg. "Trash") depend on the language * **language** is the language code for the user, eg. "en" or "et". Mailbox names for the default mailboxes (eg. "Trash") depend on the language
@ -358,8 +354,6 @@ Updates the properties of an user. Only specify these fields that you want to be
* **user** (required) is the ID of the user * **user** (required) is the ID of the user
* **name** is the updated name for the user * **name** is the updated name for the user
* **password** is the updated password for the user (do not set if you do not want to change user password) * **password** is the updated password for the user (do not set if you do not want to change user password)
* **forward** is an array of email addresses to where all messages are forwarded to
* **targetUrl** is an URL to where all messages are uploaded to
* **quota** is the maximum storage in bytes allowed for this user * **quota** is the maximum storage in bytes allowed for this user
* **retention** is the default retention time in ms for mailboxes. Messages in Trash and Junk folders have a capped retention time of 30 days. * **retention** is the default retention time in ms for mailboxes. Messages in Trash and Junk folders have a capped retention time of 30 days.
* **language** is the language code for the user, eg. "en" or "et" * **language** is the language code for the user, eg. "en" or "et"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-01-16T11:35:19.349Z", "url": "http://apidocjs.com", "version": "0.17.6" } }); define({ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-01-20T19:38:49.486Z", "url": "http://apidocjs.com", "version": "0.17.6" } });

View file

@ -1 +1 @@
{ "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "title": "WildDuck API", "url": "http://localhost:8080", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-01-16T11:35:19.349Z", "url": "http://apidocjs.com", "version": "0.17.6" } } { "name": "wildduck", "version": "1.0.0", "description": "WildDuck API docs", "title": "WildDuck API", "url": "https://api.wildduck.email", "sampleUrl": false, "defaultVersion": "0.0.0", "apidoc": "0.3.0", "generator": { "name": "apidoc", "time": "2018-01-20T19:38:49.486Z", "url": "http://apidocjs.com", "version": "0.17.6" } }

View file

@ -1226,7 +1226,9 @@ module.exports = (db, server) => {
targets: Joi.array().items( targets: Joi.array().items(
Joi.string().email(), Joi.string().email(),
Joi.string().uri({ Joi.string().uri({
scheme: [/smtps?/, /https?/] scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
}) })
), ),
forwards: Joi.number() forwards: Joi.number()
@ -1547,7 +1549,9 @@ module.exports = (db, server) => {
targets: Joi.array().items( targets: Joi.array().items(
Joi.string().email(), Joi.string().email(),
Joi.string().uri({ Joi.string().uri({
scheme: [/smtps?/, /https?/] scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
}) })
), ),
forwards: Joi.number().min(0), forwards: Joi.number().min(0),

View file

@ -1,7 +1,7 @@
{ {
"name": "wildduck", "name": "wildduck",
"version": "1.0.0", "version": "1.0.0",
"description": "WildDuck API docs. Under construction, see old docs here: https://github.com/nodemailer/wildduck/blob/master/docs/api.md", "description": "WildDuck API docs",
"title": "WildDuck API", "title": "WildDuck API",
"url": "http://localhost:8080" "url": "https://api.wildduck.email"
} }

View file

@ -189,19 +189,20 @@ module.exports = (db, server) => {
* @apiSuccess {Boolean} success Indicates successful response * @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID for the Filter * @apiSuccess {String} id ID for the Filter
* @apiSuccess {String} name Name of the Filter * @apiSuccess {String} name Name of the Filter
* @apiSuccess {String} query_from Partial match for the From: header (case insensitive) * @apiSuccess {Object} query Rules that a message must match
* @apiSuccess {String} query_to Partial match for the To:/Cc: headers (case insensitive) * @apiSuccess {String} query.from Partial match for the From: header (case insensitive)
* @apiSuccess {String} query_subject Partial match for the Subject: header (case insensitive) * @apiSuccess {String} query.to Partial match for the To:/Cc: headers (case insensitive)
* @apiSuccess {String} query_text Fulltext search against message text * @apiSuccess {String} query.subject Partial match for the Subject: header (case insensitive)
* @apiSuccess {Bolean} query_ha Does a message have to have an attachment or not * @apiSuccess {String} query.text Fulltext search against message text
* @apiSuccess {Number} query_size Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value * @apiSuccess {Bolean} query.ha Does a message have to have an attachment or not
* @apiSuccess {Bolean} action_seen If true then mark matching messages as Seen * @apiSuccess {Number} query.size Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value
* @apiSuccess {Bolean} action_flag If true then mark matching messages as Flagged * @apiSuccess {Object} action Action to take with a matching message
* @apiSuccess {Bolean} action_delete If true then do not store matching messages * @apiSuccess {Bolean} action.seen If true then mark matching messages as Seen
* @apiSuccess {Bolean} action_spam If true then store matching messags to Junk Mail folder * @apiSuccess {Bolean} action.flag If true then mark matching messages as Flagged
* @apiSuccess {String} action_mailbox Mailbox ID to store matching messages to * @apiSuccess {Bolean} action.delete If true then do not store matching messages
* @apiSuccess {String} action_forward An email address where matching messages should be forwarded to * @apiSuccess {Bolean} action.spam If true then store matching messags to Junk Mail folder
* @apiSuccess {String} action_targetUrl An URL where matching messages should be POSTed to * @apiSuccess {String} action.mailbox Mailbox ID to store matching messages to
* @apiSuccess {String[]} action.targets A list of email addresses / HTTP URLs to forward the message to
* *
* @apiError error Description of the error * @apiError error Description of the error
* *
@ -214,8 +215,12 @@ module.exports = (db, server) => {
* "success": true, * "success": true,
* "id": "5a1c0ee490a34c67e266931c", * "id": "5a1c0ee490a34c67e266931c",
* "created": "2017-11-27T13:11:00.835Z", * "created": "2017-11-27T13:11:00.835Z",
* "query_from": "Mäger", * "query": {
* "action_seen": true * "from": "Mäger"
* },
* "action": {
* "seen": true
* }
* } * }
* *
* @apiErrorExample {json} Error-Response: * @apiErrorExample {json} Error-Response:
@ -300,21 +305,26 @@ module.exports = (db, server) => {
success: true, success: true,
id: filterData._id, id: filterData._id,
name: filterData.name, name: filterData.name,
query: {},
action: {},
created: filterData.created created: filterData.created
}; };
Object.keys((filterData.query && filterData.query.headers) || {}).forEach(key => { Object.keys((filterData.query && filterData.query.headers) || {}).forEach(key => {
result['query_' + key] = filterData.query.headers[key]; result.query[key] = filterData.query.headers[key];
}); });
Object.keys(filterData.query || {}).forEach(key => { Object.keys(filterData.query || {}).forEach(key => {
if (key !== 'headers') { if (key !== 'headers') {
result['query_' + key] = filterData.query[key]; result.query[key] = filterData.query[key];
} }
}); });
Object.keys(filterData.action || {}).forEach(key => { Object.keys(filterData.action || {}).forEach(key => {
result['action_' + key] = filterData.action[key]; if (key === 'targets') {
result.action.targets = filterData.action.targets.map(target => target.value);
}
result.action[key] = filterData.action[key];
}); });
res.json(result); res.json(result);
@ -431,19 +441,20 @@ module.exports = (db, server) => {
* *
* @apiParam {String} user Users unique ID. * @apiParam {String} user Users unique ID.
* @apiParam {String} [name] Name of the Filter * @apiParam {String} [name] Name of the Filter
* @apiParam {String} [query_from] Partial match for the From: header (case insensitive) * @apiParam {Object} query Rules that a message must match
* @apiParam {String} [query_to] Partial match for the To:/Cc: headers (case insensitive) * @apiParam {String} [query.from] Partial match for the From: header (case insensitive)
* @apiParam {String} [query_subject] Partial match for the Subject: header (case insensitive) * @apiParam {String} [query.to] Partial match for the To:/Cc: headers (case insensitive)
* @apiParam {String} [query_text] Fulltext search against message text * @apiParam {String} [query.subject] Partial match for the Subject: header (case insensitive)
* @apiParam {Bolean} [query_ha] Does a message have to have an attachment or not * @apiParam {String} [query.text] Fulltext search against message text
* @apiParam {Number} [query_size] Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value * @apiParam {Bolean} [query.ha] Does a message have to have an attachment or not
* @apiParam {Bolean} [action_seen] If true then mark matching messages as Seen * @apiParam {Number} [query.size] Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value
* @apiParam {Bolean} [action_flag] If true then mark matching messages as Flagged * @apiParam {Object} action Action to take with a matching message
* @apiParam {Bolean} [action_delete] If true then do not store matching messages * @apiParam {Bolean} [action.seen] If true then mark matching messages as Seen
* @apiParam {Bolean} [action_spam] If true then store matching messags to Junk Mail folder * @apiParam {Bolean} [action.flag] If true then mark matching messages as Flagged
* @apiParam {String} [action_mailbox] Mailbox ID to store matching messages to * @apiParam {Bolean} [action.delete] If true then do not store matching messages
* @apiParam {String} [action_forward] An email address where matching messages should be forwarded to * @apiParam {Bolean} [action.spam] If true then store matching messags to Junk Mail folder
* @apiParam {String} [action_targetUrl] An URL where matching messages should be POSTed to * @apiParam {String} [action.mailbox] Mailbox ID to store matching messages to
* @apiParam {String[]} [action.targets] A list of email addresses / HTTP URLs to forward the message to
* *
* @apiSuccess {Boolean} success Indicates successful response * @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID for the created Filter * @apiSuccess {String} id ID for the created Filter
@ -454,8 +465,12 @@ module.exports = (db, server) => {
* curl -i -XPOST http://localhost:8080/users/5a1bda70bfbd1442cd96c6f0/filters \ * curl -i -XPOST http://localhost:8080/users/5a1bda70bfbd1442cd96c6f0/filters \
* -H 'Content-type: application/json' \ * -H 'Content-type: application/json' \
* -d '{ * -d '{
* "query_from": "Mäger", * "query": {
* "action_seen": true * "from": "Mäger"
* },
* "action": {
* "seen": true
* }
* }' * }'
* *
* @apiSuccessExample {json} Success-Response: * @apiSuccessExample {json} Success-Response:
@ -486,60 +501,64 @@ module.exports = (db, server) => {
.max(255) .max(255)
.empty(''), .empty(''),
query_from: Joi.string() query: Joi.object()
.trim() .keys({
.max(255) from: Joi.string()
.empty(''), .trim()
query_to: Joi.string() .max(255)
.trim() .empty(''),
.max(255) to: Joi.string()
.empty(''), .trim()
query_subject: Joi.string() .max(255)
.trim() .empty(''),
.max(255) subject: Joi.string()
.empty(''), .trim()
query_text: Joi.string() .max(255)
.trim() .empty(''),
.max(255) text: Joi.string()
.empty(''), .trim()
query_ha: Joi.boolean() .max(255)
.truthy(['Y', 'true', 'yes', 'on', 1]) .empty(''),
.falsy(['N', 'false', 'no', 'off', 0, '']) ha: Joi.boolean()
.empty(''), .truthy(['Y', 'true', 'yes', 'on', 1])
query_size: Joi.number().empty(''), .falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
action_seen: Joi.boolean() size: Joi.number().empty('')
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
action_flag: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
action_delete: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
action_spam: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
action_mailbox: Joi.string()
.hex()
.lowercase()
.length(24)
.empty(''),
action_forward: Joi.string()
.email()
.empty(''),
action_targetUrl: Joi.string()
.uri({
scheme: ['http', 'https'],
allowRelative: false,
relativeOnly: false
}) })
.empty('') .required(),
action: Joi.object()
.keys({
seen: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
flag: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
delete: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
spam: Joi.boolean()
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.empty(''),
mailbox: Joi.string()
.hex()
.lowercase()
.length(24)
.empty(''),
targets: Joi.array().items(
Joi.string().email(),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
)
})
.required()
}); });
const result = Joi.validate(req.params, schema, { const result = Joi.validate(req.params, schema, {
@ -574,48 +593,78 @@ module.exports = (db, server) => {
let hasAction = false; let hasAction = false;
['from', 'to', 'subject'].forEach(key => { ['from', 'to', 'subject'].forEach(key => {
if (result.value['query_' + key]) { if (result.value.query[key]) {
filterData.query.headers[key] = result.value['query_' + key].replace(/\s+/g, ' '); filterData.query.headers[key] = result.value.query[key].replace(/\s+/g, ' ');
hasQuery = true; hasQuery = true;
} }
}); });
if (result.value.query_text) { if (result.value.query.text) {
filterData.query.text = result.value.query_text.replace(/\s+/g, ' '); filterData.query.text = result.value.query.text.replace(/\s+/g, ' ');
hasQuery = true; hasQuery = true;
} }
if (typeof result.value.query_ha === 'boolean') { if (typeof result.value.query.ha === 'boolean') {
filterData.query.ha = result.value.query_ha; filterData.query.ha = result.value.query.ha;
hasQuery = true; hasQuery = true;
} }
if (result.value.query_size) { if (result.value.query.size) {
filterData.query.size = result.value.query_size; filterData.query.size = result.value.query.size;
hasQuery = true; hasQuery = true;
} }
['seen', 'flag', 'delete', 'spam'].forEach(key => { ['seen', 'flag', 'delete', 'spam'].forEach(key => {
if (typeof result.value['action_' + key] === 'boolean') { if (typeof result.value.action[key] === 'boolean') {
filterData.action[key] = result.value['action_' + key]; filterData.action[key] = result.value.action[key];
hasAction = true; hasAction = true;
} }
}); });
['forward', 'targetUrl'].forEach(key => { let targets = result.value.action.targets;
if (result.value['action_' + key]) {
filterData.action[key] = result.value['action_' + key]; if (targets) {
hasAction = true; for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
} }
});
filterData.action.targets = targets;
hasAction = true;
}
let checkFilterMailbox = done => { let checkFilterMailbox = done => {
if (!result.value.action_mailbox) { if (!result.value.action.mailbox) {
return done(); return done();
} }
db.database.collection('mailboxes').findOne( db.database.collection('mailboxes').findOne(
{ {
_id: new ObjectID(result.value.action_mailbox), _id: new ObjectID(result.value.action.mailbox),
user user
}, },
(err, mailboxData) => { (err, mailboxData) => {
@ -713,19 +762,20 @@ module.exports = (db, server) => {
* @apiParam {String} user Users unique ID. * @apiParam {String} user Users unique ID.
* @apiParam {String} filter Filters unique ID. * @apiParam {String} filter Filters unique ID.
* @apiParam {String} [name] Name of the Filter * @apiParam {String} [name] Name of the Filter
* @apiParam {String} [query_from] Partial match for the From: header (case insensitive) * @apiParam {Object} query Rules that a message must match
* @apiParam {String} [query_to] Partial match for the To:/Cc: headers (case insensitive) * @apiParam {String} [query.from] Partial match for the From: header (case insensitive)
* @apiParam {String} [query_subject] Partial match for the Subject: header (case insensitive) * @apiParam {String} [query.to] Partial match for the To:/Cc: headers (case insensitive)
* @apiParam {String} [query_text] Fulltext search against message text * @apiParam {String} [query.subject] Partial match for the Subject: header (case insensitive)
* @apiParam {Bolean} [query_ha] Does a message have to have an attachment or not * @apiParam {String} [query.text] Fulltext search against message text
* @apiParam {Number} [query_size] Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value * @apiParam {Bolean} [query.ha] Does a message have to have an attachment or not
* @apiParam {Bolean} [action_seen] If true then mark matching messages as Seen * @apiParam {Number} [query.size] Message size in bytes. If the value is a positive number then message needs to be larger, if negative then message needs to be smaller than abs(size) value
* @apiParam {Bolean} [action_flag] If true then mark matching messages as Flagged * @apiParam {Object} action Action to take with a matching message
* @apiParam {Bolean} [action_delete] If true then do not store matching messages * @apiParam {Bolean} [action.seen] If true then mark matching messages as Seen
* @apiParam {Bolean} [action_spam] If true then store matching messags to Junk Mail folder * @apiParam {Bolean} [action.flag] If true then mark matching messages as Flagged
* @apiParam {String} [action_mailbox] Mailbox ID to store matching messages to * @apiParam {Bolean} [action.delete] If true then do not store matching messages
* @apiParam {String} [action_forward] An email address where matching messages should be forwarded to * @apiParam {Bolean} [action.spam] If true then store matching messags to Junk Mail folder
* @apiParam {String} [action_targetUrl] An URL where matching messages should be POSTed to * @apiParam {String} [action.mailbox] Mailbox ID to store matching messages to
* @apiParam {String[]} [action.targets] A list of email addresses / HTTP URLs to forward the message to
* *
* @apiSuccess {Boolean} success Indicates successful response * @apiSuccess {Boolean} success Indicates successful response
* @apiSuccess {String} id ID for the created Filter * @apiSuccess {String} id ID for the created Filter
@ -736,8 +786,10 @@ module.exports = (db, server) => {
* curl -i -XPUT http://localhost:8080/users/59fc66a03e54454869460e45/filters/5a1c0ee490a34c67e266931c \ * curl -i -XPUT http://localhost:8080/users/59fc66a03e54454869460e45/filters/5a1c0ee490a34c67e266931c \
* -H 'Content-type: application/json' \ * -H 'Content-type: application/json' \
* -d '{ * -d '{
* "action_seen": "", * "action": {
* "action_flag": true * "seen": "",
* "flag": true
* }
* }' * }'
* *
* @apiSuccessExample {json} Success-Response: * @apiSuccessExample {json} Success-Response:
@ -772,60 +824,60 @@ module.exports = (db, server) => {
.max(255) .max(255)
.empty(''), .empty(''),
query_from: Joi.string() query: Joi.object().keys({
.trim() from: Joi.string()
.max(255) .trim()
.empty(''), .max(255)
query_to: Joi.string() .empty(''),
.trim() to: Joi.string()
.max(255) .trim()
.empty(''), .max(255)
query_subject: Joi.string() .empty(''),
.trim() subject: Joi.string()
.max(255) .trim()
.empty(''), .max(255)
query_text: Joi.string() .empty(''),
.trim() text: Joi.string()
.max(255) .trim()
.empty(''), .max(255)
query_ha: Joi.boolean() .empty(''),
.truthy(['Y', 'true', 'yes', 'on', 1]) ha: Joi.boolean()
.falsy(['N', 'false', 'no', 'off', 0, '']) .truthy(['Y', 'true', 'yes', 'on', 1])
.empty(''), .falsy(['N', 'false', 'no', 'off', 0, ''])
query_size: Joi.number().empty(''), .empty(''),
size: Joi.number().empty('')
action_seen: Joi.boolean() }),
.truthy(['Y', 'true', 'yes', 'on', 1]) action: Joi.object().keys({
.falsy(['N', 'false', 'no', 'off', 0, '']) seen: Joi.boolean()
.empty(''), .truthy(['Y', 'true', 'yes', 'on', 1])
action_flag: Joi.boolean() .falsy(['N', 'false', 'no', 'off', 0, ''])
.truthy(['Y', 'true', 'yes', 'on', 1]) .empty(''),
.falsy(['N', 'false', 'no', 'off', 0, '']) flag: Joi.boolean()
.empty(''), .truthy(['Y', 'true', 'yes', 'on', 1])
action_delete: Joi.boolean() .falsy(['N', 'false', 'no', 'off', 0, ''])
.truthy(['Y', 'true', 'yes', 'on', 1]) .empty(''),
.falsy(['N', 'false', 'no', 'off', 0, '']) delete: Joi.boolean()
.empty(''), .truthy(['Y', 'true', 'yes', 'on', 1])
action_spam: Joi.boolean() .falsy(['N', 'false', 'no', 'off', 0, ''])
.truthy(['Y', 'true', 'yes', 'on', 1]) .empty(''),
.falsy(['N', 'false', 'no', 'off', 0, '']) spam: Joi.boolean()
.empty(''), .truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
action_mailbox: Joi.string() .empty(''),
.hex() mailbox: Joi.string()
.lowercase() .hex()
.length(24) .lowercase()
.empty(''), .length(24)
action_forward: Joi.string() .empty(''),
.email() targets: Joi.array().items(
.empty(''), Joi.string().email(),
action_targetUrl: Joi.string() Joi.string().uri({
.uri({ scheme: [/smtps?/, /https?/],
scheme: ['http', 'https'], allowRelative: false,
allowRelative: false, relativeOnly: false
relativeOnly: false })
}) )
.empty('') })
}); });
const result = Joi.validate(req.params, schema, { const result = Joi.validate(req.params, schema, {
@ -847,78 +899,117 @@ module.exports = (db, server) => {
let $set = {}; let $set = {};
let $unset = {}; let $unset = {};
let hasChanges = false;
if (result.value.name) { if (result.value.name) {
$set.name = result.value.name; $set.name = result.value.name;
hasChanges = true;
} }
let hasQuery = false; if (result.value.query) {
let hasAction = false; ['from', 'to', 'subject'].forEach(key => {
if (result.value.query[key]) {
$set['query.headers.' + key] = result.value.query[key].replace(/\s+/g, ' ');
hasChanges = true;
} else if (key in req.params.query) {
// delete empty values
$unset['query.headers.' + key] = true;
hasChanges = true;
}
});
['from', 'to', 'subject'].forEach(key => { if (result.value.query.text) {
if (result.value['query_' + key]) { $set['query.text'] = result.value.query.text.replace(/\s+/g, ' ');
$set['query.headers.' + key] = result.value['query_' + key].replace(/\s+/g, ' '); hasChanges = true;
hasQuery = true; } else if ('text' in req.params.query) {
} else if ('query_' + key in req.params) { $unset['query.text'] = true;
$unset['query.headers.' + key] = true; hasChanges = true;
hasQuery = true;
} }
});
if (result.value.query_text) { if (typeof result.value.query.ha === 'boolean') {
$set['query.text'] = result.value.query_text.replace(/\s+/g, ' '); $set['query.ha'] = result.value.query.ha;
hasQuery = true; hasChanges = true;
} else if ('query_text' in req.params) { } else if ('ha' in req.params.query) {
$unset['query.text'] = true; $unset['query.ha'] = true;
hasQuery = true; hasChanges = true;
}
if (typeof result.value.query_ha === 'boolean') {
$set['query.ha'] = result.value.query_ha;
hasQuery = true;
} else if ('query_ha' in req.params) {
$unset['query.ha'] = true;
hasQuery = true;
}
if (result.value.query_size) {
$set['query.size'] = result.value.query_size;
hasQuery = true;
} else if ('query_size' in req.params) {
$unset['query.size'] = true;
hasQuery = true;
}
['seen', 'flag', 'delete', 'spam'].forEach(key => {
if (typeof result.value['action_' + key] === 'boolean') {
$set['action.' + key] = result.value['action_' + key];
hasAction = true;
} else if ('action_' + key in req.params) {
$unset['action.' + key] = true;
hasAction = true;
} }
});
['forward', 'targetUrl'].forEach(key => { if (result.value.query.size) {
if (result.value['action_' + key]) { $set['query.size'] = result.value.query.size;
$set['action.' + key] = result.value['action_' + key]; hasChanges = true;
hasAction = true; } else if ('size' in req.params.query) {
} else if ('action_' + key in req.params) { $unset['query.size'] = true;
$unset['action.' + key] = true; hasChanges = true;
hasAction = true;
} }
}); }
if (result.value.action) {
['seen', 'flag', 'delete', 'spam'].forEach(key => {
if (typeof result.value.action[key] === 'boolean') {
$set['action.' + key] = result.value.action[key];
hasChanges = true;
} else if (key in req.params.action) {
$unset['action.' + key] = true;
hasChanges = true;
}
});
let targets = result.value.action.targets;
if (targets) {
for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
}
$set['action.targets'] = targets;
hasChanges = true;
} else if ('targets' in req.params.action) {
$unset['action.targets'] = true;
hasChanges = true;
}
}
let checkFilterMailbox = done => { let checkFilterMailbox = done => {
if (!result.value.action_mailbox) { if (!result.value.action) {
if ('action_mailbox' in req.params) { return done();
}
if (!result.value.action.mailbox) {
if ('mailbox' in req.params.action) {
$unset['action.mailbox'] = true; $unset['action.mailbox'] = true;
hasAction = true; hasChanges = true;
} }
return done(); return done();
} }
db.database.collection('mailboxes').findOne( db.database.collection('mailboxes').findOne(
{ {
_id: new ObjectID(result.value.action_mailbox), _id: new ObjectID(result.value.action.mailbox),
user user
}, },
(err, mailboxData) => { (err, mailboxData) => {
@ -936,14 +1027,14 @@ module.exports = (db, server) => {
return next(); return next();
} }
$set['action.mailbox'] = mailboxData._id; $set['action.mailbox'] = mailboxData._id;
hasAction = true; hasChanges = true;
done(); done();
} }
); );
}; };
checkFilterMailbox(() => { checkFilterMailbox(() => {
if (!hasQuery && !hasAction) { if (!hasChanges) {
res.json({ res.json({
error: 'No changes' error: 'No changes'
}); });
@ -1041,16 +1132,24 @@ function getFilterStrings(filter, mailboxes) {
} else { } else {
return ['keep in INBOX']; return ['keep in INBOX'];
} }
case 'forward': case 'targets':
if (filter.action[key]) { if (filter.action[key]) {
return ['forward to', filter.action[key]]; return [
} 'forward to',
break; filter.action[key]
case 'targetUrl': .map(target => {
if (filter.action[key]) { switch (target.type) {
let url = filter.action[key]; case 'http': {
let parsed = urllib.parse(url); let parsed = urllib.parse(target.value);
return ['upload to', parsed.hostname || parsed.host]; return parsed.hostname || parsed.host;
}
default:
return target.value;
}
})
.join(', ')
];
} }
break; break;
case 'spam': case 'spam':

View file

@ -56,8 +56,10 @@ module.exports = (db, server, messageHandler) => {
* @apiSuccess {String} results.intro First 128 bytes of the message * @apiSuccess {String} results.intro First 128 bytes of the message
* @apiSuccess {Boolean} results.attachments Does the message have attachments * @apiSuccess {Boolean} results.attachments Does the message have attachments
* @apiSuccess {Boolean} results.seen Is this message alread seen or not * @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.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.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 {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 {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 * @apiSuccess {Object} results.contentType.params An object with Content-Type params as key-value pairs
@ -93,6 +95,8 @@ module.exports = (db, server, messageHandler) => {
* "deleted": false, * "deleted": false,
* "flagged": true, * "flagged": true,
* "draft": false, * "draft": false,
* "answered": false,
* "forwarded": false,
* "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1", * "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1",
* "contentType": { * "contentType": {
* "value": "multipart/mixed", * "value": "multipart/mixed",
@ -233,7 +237,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true, undeleted: true,
flagged: true, flagged: true,
draft: true, draft: true,
thread: true thread: true,
flags: true
}, },
paginatedField: 'idate', paginatedField: 'idate',
sortAscending sortAscending
@ -663,7 +668,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true, undeleted: true,
flagged: true, flagged: true,
draft: true, draft: true,
thread: true thread: true,
flags: true
}, },
paginatedField: '_id', paginatedField: '_id',
sortAscending: false sortAscending: false
@ -904,6 +910,7 @@ module.exports = (db, server, messageHandler) => {
undeleted: true, undeleted: true,
flagged: true, flagged: true,
draft: true, draft: true,
flags: true,
attachments: true, attachments: true,
html: true, html: true,
text: true, text: true,
@ -1028,6 +1035,8 @@ module.exports = (db, server, messageHandler) => {
deleted: !messageData.undeleted, deleted: !messageData.undeleted,
flagged: messageData.flagged, flagged: messageData.flagged,
draft: messageData.draft, draft: messageData.draft,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded'),
html: messageData.html, html: messageData.html,
text: messageData.text, text: messageData.text,
forwardTargets: messageData.forwardTargets, forwardTargets: messageData.forwardTargets,
@ -2439,7 +2448,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true, undeleted: true,
flagged: true, flagged: true,
draft: true, draft: true,
thread: true thread: true,
flags: true
}, },
paginatedField: '_id', paginatedField: '_id',
sortAscending sortAscending
@ -2669,6 +2679,7 @@ module.exports = (db, server, messageHandler) => {
undeleted: true, undeleted: true,
flagged: true, flagged: true,
draft: true, draft: true,
flags: true,
attachments: true, attachments: true,
html: true, html: true,
text: true, text: true,
@ -2776,6 +2787,8 @@ module.exports = (db, server, messageHandler) => {
deleted: !messageData.undeleted, deleted: !messageData.undeleted,
flagged: messageData.flagged, flagged: messageData.flagged,
draft: messageData.draft, draft: messageData.draft,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded'),
html: messageData.html, html: messageData.html,
text: messageData.text, text: messageData.text,
forwardTargets: messageData.forwardTargets, forwardTargets: messageData.forwardTargets,
@ -3124,7 +3137,9 @@ function formatMessageListing(messageData) {
seen: !messageData.unseen, seen: !messageData.unseen,
deleted: !messageData.undeleted, deleted: !messageData.undeleted,
flagged: messageData.flagged, flagged: messageData.flagged,
draft: messageData.draft draft: messageData.draft,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded')
}; };
let parsedContentType = parsedHeader['content-type']; let parsedContentType = parsedHeader['content-type'];

View file

@ -76,106 +76,144 @@ module.exports = (db, server, messageHandler, userHandler) => {
return done(null, false); return done(null, false);
} }
query.user = user; query.user = user;
db.users.collection('messages').findOne(
query,
{
fields: {
'mimeTree.parsedHeader': true,
thread: true
}
},
(err, messageData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
let headers = (messageData && messageData.mimeTree && messageData.mimeTree.parsedHeader) || {}; let getMessage = next => {
let subject = headers.subject || ''; let updateable = ['reply', 'replyAll', 'forward'];
try { if (!options.reference || !updateable.includes(options.reference.action)) {
subject = libmime.decodeWords(subject).trim(); return db.database.collection('messages').findOne(
} catch (E) { query,
// failed to parse value {
} fields: {
'mimeTree.parsedHeader': true,
if (!/^\w+: /.test(subject)) { thread: true
subject = ((options.reference.action === 'forward' ? 'Fwd' : 'Re') + ': ' + subject).trim();
}
let sender = headers['reply-to'] || headers.from || headers.sender;
let replyTo = [];
let replyCc = [];
let uniqueRecipients = new Set();
let checkAddress = (target, addr) => {
let address = tools.normalizeAddress(addr.address);
if (address !== userData.address && !uniqueRecipients.has(address)) {
uniqueRecipients.add(address);
if (addr.name) {
try {
addr.name = libmime.decodeWords(addr.name).trim();
} catch (E) {
// failed to parse value
}
} }
target.push(addr); },
} next
}; );
if (sender && sender.address) {
checkAddress(replyTo, sender);
}
if (options.reference.action === 'replyAll') {
[].concat(headers.to || []).forEach(addr => {
let walk = addr => {
if (addr.address) {
checkAddress(replyTo, addr);
} else if (addr.group) {
addr.group.forEach(walk);
}
};
walk(addr);
});
[].concat(headers.cc || []).forEach(addr => {
let walk = addr => {
if (addr.address) {
checkAddress(replyCc, addr);
} else if (addr.group) {
addr.group.forEach(walk);
}
};
walk(addr);
});
}
let messageId = (headers['message-id'] || '').trim();
let references = (headers.references || '')
.trim()
.replace(/\s+/g, ' ')
.split(' ')
.filter(mid => mid);
if (messageId && !references.includes(messageId)) {
references.unshift(messageId);
}
if (references.length > 50) {
references = references.slice(0, 50);
}
let referenceData = {
replyTo,
replyCc,
subject,
thread: messageData.thread,
inReplyTo: messageId,
references: references.join(' ')
};
return done(null, referenceData);
} }
); let $addToSet = {};
switch (options.reference.action) {
case 'reply':
case 'replyAll':
$addToSet.flags = '\\Answered';
break;
case 'forward':
$addToSet.flags = '$Forwarded';
break;
}
db.database.collection('messages').findOneAndUpdate(
query,
{
$addToSet
},
{
returnOriginal: false,
projection: {
'mimeTree.parsedHeader': true,
thread: true
}
},
(err, r) => {
if (err) {
return next(err);
}
return next(null, r && r.value);
}
);
};
getMessage((err, messageData) => {
if (err) {
err.code = 'InternalDatabaseError';
return callback(err);
}
let headers = (messageData && messageData.mimeTree && messageData.mimeTree.parsedHeader) || {};
let subject = headers.subject || '';
try {
subject = libmime.decodeWords(subject).trim();
} catch (E) {
// failed to parse value
}
if (!/^\w+: /.test(subject)) {
subject = ((options.reference.action === 'forward' ? 'Fwd' : 'Re') + ': ' + subject).trim();
}
let sender = headers['reply-to'] || headers.from || headers.sender;
let replyTo = [];
let replyCc = [];
let uniqueRecipients = new Set();
let checkAddress = (target, addr) => {
let address = tools.normalizeAddress(addr.address);
if (address !== userData.address && !uniqueRecipients.has(address)) {
uniqueRecipients.add(address);
if (addr.name) {
try {
addr.name = libmime.decodeWords(addr.name).trim();
} catch (E) {
// failed to parse value
}
}
target.push(addr);
}
};
if (sender && sender.address) {
checkAddress(replyTo, sender);
}
if (options.reference.action === 'replyAll') {
[].concat(headers.to || []).forEach(addr => {
let walk = addr => {
if (addr.address) {
checkAddress(replyTo, addr);
} else if (addr.group) {
addr.group.forEach(walk);
}
};
walk(addr);
});
[].concat(headers.cc || []).forEach(addr => {
let walk = addr => {
if (addr.address) {
checkAddress(replyCc, addr);
} else if (addr.group) {
addr.group.forEach(walk);
}
};
walk(addr);
});
}
let messageId = (headers['message-id'] || '').trim();
let references = (headers.references || '')
.trim()
.replace(/\s+/g, ' ')
.split(' ')
.filter(mid => mid);
if (messageId && !references.includes(messageId)) {
references.unshift(messageId);
}
if (references.length > 50) {
references = references.slice(0, 50);
}
let referenceData = {
replyTo,
replyCc,
subject,
thread: messageData.thread,
inReplyTo: messageId,
references: references.join(' ')
};
return done(null, referenceData);
});
}; };
getReferencedMessage((err, referenceData) => { getReferencedMessage((err, referenceData) => {
@ -288,57 +326,56 @@ module.exports = (db, server, messageHandler, userHandler) => {
let collector = new StreamCollect(); let collector = new StreamCollect();
let compiledEnvelope = compiled.getEnvelope(); let compiledEnvelope = compiled.getEnvelope();
messageHandler.counters.ttlcounter( let messageId = new ObjectID();
'wdr:' + userData._id.toString(), let addToDeliveryQueue = next => {
compiledEnvelope.to.length, if (!compiledEnvelope.to || !compiledEnvelope.to.length || options.uploadOnly) {
userData.recipients, // no delivery, just build the message
false, collector.on('data', () => false); //drain
(err, result) => { collector.on('end', () => {
if (err) { next(null, false);
err.code = 'ERRREDIS'; });
return callback(err); collector.once('error', err => {
} next(err);
});
let stream = compiled.createReadStream();
stream.once('error', err => collector.emit('error', err));
stream.pipe(collector);
return;
}
let success = result.success; messageHandler.counters.ttlcounter(
let sent = result.value; 'wdr:' + userData._id.toString(),
let ttl = result.ttl; compiledEnvelope.to.length,
userData.recipients,
let ttlHuman = false; false,
if (ttl) { (err, result) => {
if (ttl < 60) { if (err) {
ttlHuman = ttl + ' seconds'; err.code = 'ERRREDIS';
} else if (ttl < 3600) { return callback(err);
ttlHuman = Math.round(ttl / 60) + ' minutes';
} else {
ttlHuman = Math.round(ttl / 3600) + ' hours';
} }
}
if (!success) { let success = result.success;
log.info('API', 'RCPTDENY denied sent=%s allowed=%s expires=%ss.', sent, userData.recipients, ttl); let sent = result.value;
let err = new Error( let ttl = result.ttl;
'You reached a daily sending limit for your account' + (ttl ? '. Limit expires in ' + ttlHuman : '')
);
err.code = 'ERRSENDINGLIMIT';
return setImmediate(() => callback(err));
}
let messageId = new ObjectID(); let ttlHuman = false;
let addToDeliveryQueue = next => { if (ttl) {
if (!compiledEnvelope.to || !compiledEnvelope.to.length || options.uploadOnly) { if (ttl < 60) {
// no delivery, just build the message ttlHuman = ttl + ' seconds';
collector.on('data', () => false); //drain } else if (ttl < 3600) {
collector.on('end', () => { ttlHuman = Math.round(ttl / 60) + ' minutes';
next(null, false); } else {
}); ttlHuman = Math.round(ttl / 3600) + ' hours';
collector.once('error', err => { }
next(err); }
});
let stream = compiled.createReadStream();
stream.once('error', err => collector.emit('error', err));
stream.pipe(collector);
return; if (!success) {
log.info('API', 'RCPTDENY denied sent=%s allowed=%s expires=%ss.', sent, userData.recipients, ttl);
let err = new Error(
'You reached a daily sending limit for your account' + (ttl ? '. Limit expires in ' + ttlHuman : '')
);
err.code = 'ERRSENDINGLIMIT';
return setImmediate(() => callback(err));
} }
// push message to outbound queue // push message to outbound queue
@ -368,82 +405,94 @@ module.exports = (db, server, messageHandler, userHandler) => {
stream.once('error', err => message.emit('error', err)); stream.once('error', err => message.emit('error', err));
stream.pipe(collector).pipe(message); stream.pipe(collector).pipe(message);
} }
}; }
);
};
addToDeliveryQueue((err, outbound) => { addToDeliveryQueue((err, outbound) => {
if (err) { if (err) {
// ignore // ignore
}
if (overQuota) {
log.info('API', 'STOREFAIL user=%s error=%s', user, 'Over quota');
return callback(null, {
id: false,
mailbox: false,
queueId: outbound,
overQuota: true
});
}
// Checks if the message needs to be encrypted before storing it
messageHandler.encryptMessage(
userData.encryptMessages ? userData.pubKey : false,
{ chunks: collector.chunks, chunklen: collector.chunklen },
(err, encrypted) => {
let raw = false;
if (!err && encrypted) {
// message was encrypted, so use the result instead of raw
raw = encrypted;
} }
if (overQuota) {
log.info('API', 'STOREFAIL user=%s error=%s', user, 'Over quota'); let meta = {
return callback(null, { source: 'API',
id: false, from: compiledEnvelope.from,
mailbox: false, to: compiledEnvelope.to,
queueId: outbound, origin: options.ip,
overQuota: true sess: options.sess,
time: new Date()
};
if (options.meta) {
Object.keys(options.meta || {}).forEach(key => {
if (!(key in meta)) {
meta[key] = options.meta[key];
}
}); });
} }
// Checks if the message needs to be encrypted before storing it let messageOptions = {
messageHandler.encryptMessage( user: userData._id,
userData.encryptMessages ? userData.pubKey : false, [options.mailbox ? 'mailbox' : 'specialUse']: options.mailbox
{ chunks: collector.chunks, chunklen: collector.chunklen }, ? new ObjectID(options.mailbox)
(err, encrypted) => { : options.isDraft ? '\\Drafts' : '\\Sent',
let raw = false;
if (!err && encrypted) {
// message was encrypted, so use the result instead of raw
raw = encrypted;
}
let messageOptions = { outbound,
user: userData._id,
[options.mailbox ? 'mailbox' : 'specialUse']: options.mailbox ? new ObjectID(options.mailbox) : '\\Sent',
outbound, meta,
meta: { date: false,
source: 'API', flags: ['\\Seen'].concat(options.isDraft ? '\\Draft' : []),
from: compiledEnvelope.from,
to: compiledEnvelope.to,
origin: options.ip,
sess: options.sess,
time: new Date()
},
date: false, // always insert drafts, otherwise skip
flags: ['\\Seen'], skipExisting: !options.isDraft
};
// if similar message exists, then skip if (raw) {
skipExisting: true messageOptions.raw = raw;
}; } else {
messageOptions.raw = Buffer.concat(collector.chunks, collector.chunklen);
}
if (raw) { messageHandler.add(messageOptions, (err, success, info) => {
messageOptions.raw = raw; if (err) {
} else { log.error('API', 'SUBMITFAIL user=%s error=%s', user, err.message);
messageOptions.raw = Buffer.concat(collector.chunks, collector.chunklen); err.code = 'SUBMITFAIL';
} return callback(err);
} else if (!info) {
messageHandler.add(messageOptions, (err, success, info) => { log.info('API', 'SUBMITSKIP user=%s message=already exists', user);
if (err) { return callback(null, false);
log.error('API', 'SUBMITFAIL user=%s error=%s', user, err.message);
err.code = 'SUBMITFAIL';
return callback(err);
} else if (!info) {
log.info('API', 'SUBMITSKIP user=%s message=already exists', user);
return callback(null, false);
}
return callback(null, {
id: info.uid,
mailbox: info.mailbox,
queueId: outbound
});
});
} }
);
}); return callback(null, {
} id: info.uid,
); mailbox: info.mailbox,
queueId: outbound
});
});
}
);
});
}); });
}); });
}); });
@ -469,6 +518,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiParam {String} reference.action Either <code>reply</code>, <code>replyAll</code> or <code>forward</code> * @apiParam {String} reference.action Either <code>reply</code>, <code>replyAll</code> or <code>forward</code>
* @apiParam {String} [mailbox] Mailbox ID where to upload the message. If not set then message is uploaded to Sent Mail folder. * @apiParam {String} [mailbox] Mailbox ID where to upload the message. If not set then message is uploaded to Sent Mail folder.
* @apiParam {Boolean} [uploadOnly=false] If <code>true</code> then generated message is not added to the sending queue * @apiParam {Boolean} [uploadOnly=false] If <code>true</code> then generated message is not added to the sending queue
* @apiParam {Boolean} [isDraft=false] If <code>true</code> then treats this message as draft (should be used with uploadOnly=true)
* @apiParam {String} [sendTime] Datestring for delivery if message should be sent some later time * @apiParam {String} [sendTime] Datestring for delivery if message should be sent some later time
* @apiParam {Object} [envelope] SMTP envelope. If not provided then resolved either from message headers or from referenced message * @apiParam {Object} [envelope] SMTP envelope. If not provided then resolved either from message headers or from referenced message
* @apiParam {Object} [envelope.from] Sender information. If not set then it is resolved to User's default address * @apiParam {Object} [envelope.from] Sender information. If not set then it is resolved to User's default address
@ -498,6 +548,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiParam {String} [attachments.filename] Attachment filename * @apiParam {String} [attachments.filename] Attachment filename
* @apiParam {String} [attachments.contentType] MIME type for the attachment file * @apiParam {String} [attachments.contentType] MIME type for the attachment file
* @apiParam {String} [attachments.cid] Content-ID value if you want to reference to this attachment from HTML formatted message * @apiParam {String} [attachments.cid] Content-ID value if you want to reference to this attachment from HTML formatted message
* @apiParam {Object} [meta] Custom metainfo for the message
* @apiParam {String} [sess] Session identifier for the logs * @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs * @apiParam {String} [ip] IP address for the logs
* *
@ -713,6 +764,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
.max(255) .max(255)
}) })
), ),
meta: Joi.object().unknown(true),
sess: Joi.string().max(255), sess: Joi.string().max(255),
ip: Joi.string().ip({ ip: Joi.string().ip({
version: ['ipv4', 'ipv6'], version: ['ipv4', 'ipv6'],

View file

@ -41,7 +41,7 @@ module.exports = (db, server, userHandler) => {
* @apiSuccess {String} results.name Name of the User * @apiSuccess {String} results.name Name of the User
* @apiSuccess {String} results.address Main email address of the User * @apiSuccess {String} results.address Main email address of the User
* @apiSuccess {String[]} results.tags List of tags associated with the User' * @apiSuccess {String[]} results.tags List of tags associated with the User'
* @apiSuccess {String[]} results.forward A list of email addresses to forward all incoming emails * @apiSuccess {String[]} targets List of forwarding targets
* @apiSuccess {Boolean} results.encryptMessages If <code>true</code> then received messages are encrypted * @apiSuccess {Boolean} results.encryptMessages If <code>true</code> then received messages are encrypted
* @apiSuccess {Boolean} results.encryptForwarded If <code>true</code> then forwarded messages are encrypted * @apiSuccess {Boolean} results.encryptForwarded If <code>true</code> then forwarded messages are encrypted
* @apiSuccess {Object} results.quota Quota usage limits * @apiSuccess {Object} results.quota Quota usage limits
@ -216,8 +216,7 @@ module.exports = (db, server, userHandler) => {
address: true, address: true,
tags: true, tags: true,
storageUsed: true, storageUsed: true,
forward: true, targets: true,
targetUrl: true,
quota: true, quota: true,
activated: true, activated: true,
disabled: true, disabled: true,
@ -260,8 +259,7 @@ module.exports = (db, server, userHandler) => {
name: userData.name, name: userData.name,
address: userData.address, address: userData.address,
tags: userData.tags || [], tags: userData.tags || [],
forward: [].concat(userData.forward || []), targets: userData.targets && userData.targets.map(t => t.value),
targetUrl: userData.targetUrl,
encryptMessages: !!userData.encryptMessages, encryptMessages: !!userData.encryptMessages,
encryptForwarded: !!userData.encryptForwarded, encryptForwarded: !!userData.encryptForwarded,
quota: { quota: {
@ -302,8 +300,7 @@ module.exports = (db, server, userHandler) => {
* @apiParam {Boolean} [encryptForwarded] If <code>true</code> then forwarded messages are encrypted * @apiParam {Boolean} [encryptForwarded] If <code>true</code> then forwarded messages are encrypted
* @apiParam {String} [pubKey] Public PGP key for the User that is used for encryption. Use empty string to remove the key * @apiParam {String} [pubKey] Public PGP key for the User that is used for encryption. Use empty string to remove the key
* @apiParam {String} [language] Language code for the User * @apiParam {String} [language] Language code for the User
* @apiParam {String[]} [forward] A list of email addresses to forward all incoming emails * @apiParam {String[]} [targets] An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25")
* @apiParam {String} [targetUrl] An URL to post all incoming emails
* @apiParam {Number} [quota] Allowed quota of the user in bytes * @apiParam {Number} [quota] Allowed quota of the user in bytes
* @apiParam {Number} [recipients] How many messages per 24 hour can be sent * @apiParam {Number} [recipients] How many messages per 24 hour can be sent
* @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded * @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded
@ -375,8 +372,14 @@ module.exports = (db, server, userHandler) => {
.default(0), .default(0),
name: Joi.string().max(256), name: Joi.string().max(256),
forward: Joi.array().items(Joi.string().email()), targets: Joi.array().items(
targetUrl: Joi.string().max(256), Joi.string().email(),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
quota: Joi.number() quota: Joi.number()
.min(0) .min(0)
@ -434,8 +437,40 @@ module.exports = (db, server, userHandler) => {
return next(); return next();
} }
if (result.value.forward) { let targets = result.value.targets;
result.value.forward = [].concat(result.value.forward || []).map(fwd => tools.normalizeAddress(fwd));
if (targets) {
for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
}
result.value.targets = targets;
} }
if ('pubKey' in req.params && !result.value.pubKey) { if ('pubKey' in req.params && !result.value.pubKey) {
@ -616,8 +651,7 @@ module.exports = (db, server, userHandler) => {
* @apiSuccess {String} keyInfo.name Name listed in public key * @apiSuccess {String} keyInfo.name Name listed in public key
* @apiSuccess {String} keyInfo.address E-mail address listed in public key * @apiSuccess {String} keyInfo.address E-mail address listed in public key
* @apiSuccess {String} keyInfo.fingerprint Fingerprint of the public key * @apiSuccess {String} keyInfo.fingerprint Fingerprint of the public key
* @apiSuccess {String[]} forward A list of email addresses to forward all incoming emails * @apiSuccess {String[]} targets List of forwarding targets
* @apiSuccess {String} targetUrl An URL to post all incoming emails
* @apiSuccess {Object} limits Account limits and usage * @apiSuccess {Object} limits Account limits and usage
* @apiSuccess {Object} limits.quota Quota usage limits * @apiSuccess {Object} limits.quota Quota usage limits
* @apiSuccess {Number} limits.quota.allowed Allowed quota of the user in bytes * @apiSuccess {Number} limits.quota.allowed Allowed quota of the user in bytes
@ -654,8 +688,10 @@ module.exports = (db, server, userHandler) => {
* "encryptForwarded": false, * "encryptForwarded": false,
* "pubKey": "", * "pubKey": "",
* "keyInfo": false, * "keyInfo": false,
* "forward": [], * "targets": [
* "targetUrl": "", * "my.old.address@example.com",
* "smtp://mx2.zone.eu:25"
* ],
* "limits": { * "limits": {
* "quota": { * "quota": {
* "allowed": 107374182400, * "allowed": 107374182400,
@ -771,8 +807,7 @@ module.exports = (db, server, userHandler) => {
pubKey: userData.pubKey, pubKey: userData.pubKey,
keyInfo: getKeyInfo(userData.pubKey), keyInfo: getKeyInfo(userData.pubKey),
forward: [].concat(userData.forward || []), targets: [].concat(userData.targets || []),
targetUrl: userData.targetUrl,
limits: { limits: {
quota: { quota: {
@ -823,8 +858,7 @@ module.exports = (db, server, userHandler) => {
* @apiParam {Boolean} [encryptForwarded] If <code>true</code> then forwarded messages are encrypted * @apiParam {Boolean} [encryptForwarded] If <code>true</code> then forwarded messages are encrypted
* @apiParam {String} [pubKey] Public PGP key for the User that is used for encryption. Use empty string to remove the key * @apiParam {String} [pubKey] Public PGP key for the User that is used for encryption. Use empty string to remove the key
* @apiParam {String} [language] Language code for the User * @apiParam {String} [language] Language code for the User
* @apiParam {String[]} [forward] A list of email addresses to forward all incoming emails * @apiParam {String[]} [targets] An array of forwarding targets. The value could either be an email address or a relay url to next MX server ("smtp://mx2.zone.eu:25")
* @apiParam {String} [targetUrl] An URL to post all incoming emails
* @apiParam {Number} [quota] Allowed quota of the user in bytes * @apiParam {Number} [quota] Allowed quota of the user in bytes
* @apiParam {Number} [recipients] How many messages per 24 hour can be sent * @apiParam {Number} [recipients] How many messages per 24 hour can be sent
* @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded * @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded
@ -884,10 +918,14 @@ module.exports = (db, server, userHandler) => {
name: Joi.string() name: Joi.string()
.empty('') .empty('')
.max(256), .max(256),
forward: Joi.array().items(Joi.string().email()), targets: Joi.array().items(
targetUrl: Joi.string() Joi.string().email(),
.empty('') Joi.string().uri({
.max(256), scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
pubKey: Joi.string() pubKey: Joi.string()
.empty('') .empty('')
@ -942,12 +980,40 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user); let user = new ObjectID(result.value.user);
if (result.value.forward) { let targets = result.value.targets;
result.value.forward = [].concat(result.value.forward || []).map(fwd => tools.normalizeAddress(fwd));
}
if (!result.value.targetUrl && 'targetUrl' in req.params) { if (targets) {
result.value.targetUrl = ''; for (let i = 0, len = targets.length; i < len; i++) {
let target = targets[i];
if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) {
// email
targets[i] = {
id: new ObjectID(),
type: 'mail',
value: target
};
} else if (/^smtps?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'relay',
value: target
};
} else if (/^https?:/i.test(target)) {
targets[i] = {
id: new ObjectID(),
type: 'http',
value: target
};
} else {
res.json({
error: 'Unknown target type "' + target + '"',
code: 'InputValidationError'
});
return next();
}
}
result.value.targets = targets;
} }
if (!result.value.name && 'name' in req.params) { if (!result.value.name && 'name' in req.params) {

View file

@ -80,8 +80,7 @@ class FilterHandler {
let fields = { let fields = {
name: true, name: true,
forwards: true, forwards: true,
forward: true, targets: true,
targetUrl: true,
autoreply: true, autoreply: true,
encryptMessages: true, encryptMessages: true,
encryptForwarded: true, encryptForwarded: true,
@ -266,16 +265,9 @@ class FilterHandler {
// apply matching filter // apply matching filter
Object.keys(filter.action).forEach(key => { Object.keys(filter.action).forEach(key => {
if (key === 'forward') { if (key === 'targets') {
[].concat(filter.action[key] || []).forEach(address => { [].concat(filter.action[key] || []).forEach(target => {
forwardTargets.set(address, { type: 'mail', value: address }); forwardTargets.set(target.value, target);
});
return;
}
if (key === 'targetUrl') {
[].concat(filter.action[key] || []).forEach(address => {
forwardTargets.set(address, { type: 'http', value: address });
}); });
return; return;
} }
@ -328,20 +320,13 @@ class FilterHandler {
}; };
let forwardMessage = done => { let forwardMessage = done => {
if (userData.forward && !filterActions.get('delete')) { if (userData.targets && !filterActions.get('delete')) {
// forward to default recipient only if the message is not deleted // forward to default recipient only if the message is not deleted
(Array.isArray(userData.forward) ? userData.forward : [].concat(userData.forward || [])).forEach(forward => { userData.targets.forEach(target => {
if (forward) { forwardTargets.set(target.value, target);
forwardTargets.set(forward, { type: 'mail', value: forward });
}
}); });
} }
if (userData.targetUrl && !filterActions.get('delete')) {
// forward to default URL only if the message is not deleted
forwardTargets.set(userData.targetUrl, { type: 'http', value: userData.targetUrl });
}
// never forward messages marked as spam // never forward messages marked as spam
if (!forwardTargets.size || filterActions.get('spam')) { if (!forwardTargets.size || filterActions.get('spam')) {
return setImmediate(done); return setImmediate(done);

View file

@ -748,8 +748,7 @@ class UserHandler {
recipients: data.recipients || 0, recipients: data.recipients || 0,
forwards: data.forwards || 0, forwards: data.forwards || 0,
forward: [].concat(data.forward || []), targets: [].concat(data.targets || []),
targetUrl: data.targetUrl || '',
// autoreply status // autoreply status
// off by default, can be changed later by user through the API // off by default, can be changed later by user through the API

View file

@ -76,8 +76,7 @@ const serverOptions = {
{ {
name: true, name: true,
forwards: true, forwards: true,
forward: true, targets: true,
targetUrl: true,
autoreply: true, autoreply: true,
encryptMessages: true, encryptMessages: true,
encryptForwarded: true, encryptForwarded: true,

View file

@ -8,7 +8,10 @@
"test": "mongo --eval 'db.dropDatabase()' wildduck-test && redis-cli -n 13 flushdb && NODE_ENV=test grunt", "test": "mongo --eval 'db.dropDatabase()' wildduck-test && redis-cli -n 13 flushdb && NODE_ENV=test grunt",
"apidoc": "apidoc -i lib/api/ -o docs/" "apidoc": "apidoc -i lib/api/ -o docs/"
}, },
"keywords": ["imap", "mail server"], "keywords": [
"imap",
"mail server"
],
"author": "Andris Reinman", "author": "Andris Reinman",
"license": "EUPL-1.1+", "license": "EUPL-1.1+",
"devDependencies": { "devDependencies": {
@ -40,7 +43,7 @@
"iconv-lite": "0.4.19", "iconv-lite": "0.4.19",
"ioredfour": "1.0.2-ioredis", "ioredfour": "1.0.2-ioredis",
"ioredis": "3.2.2", "ioredis": "3.2.2",
"joi": "13.1.0", "joi": "13.1.1",
"js-yaml": "3.10.0", "js-yaml": "3.10.0",
"key-fingerprint": "1.1.0", "key-fingerprint": "1.1.0",
"libbase64": "1.0.2", "libbase64": "1.0.2",
@ -53,7 +56,7 @@
"mongodb": "3.0.1", "mongodb": "3.0.1",
"mongodb-extended-json": "1.10.0", "mongodb-extended-json": "1.10.0",
"node-forge": "^0.7.1", "node-forge": "^0.7.1",
"nodemailer": "4.4.1", "nodemailer": "4.4.2",
"npmlog": "4.1.2", "npmlog": "4.1.2",
"openpgp": "2.6.1", "openpgp": "2.6.1",
"qrcode": "1.2.0", "qrcode": "1.2.0",