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
**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.
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,
"address": "testuser01@example.com",
"tags": ["green", "blue"],
"forward": [],
"targetUrl": "",
"encryptMessages": false,
"encryptForwarded": false,
"quota": {
@ -270,8 +270,6 @@ Response for a successful operation:
"encryptForwarded": false,
"pubKey": "",
"keyInfo": false,
"forward": [],
"targetUrl": "",
"limits": {
"quota": {
"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
* **emptyAddress** if true, then do not set up an address 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
* **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
@ -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
* **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)
* **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
* **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"

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

View file

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

View file

@ -56,8 +56,10 @@ module.exports = (db, server, messageHandler) => {
* @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.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
@ -93,6 +95,8 @@ module.exports = (db, server, messageHandler) => {
* "deleted": false,
* "flagged": true,
* "draft": false,
* "answered": false,
* "forwarded": false,
* "url": "/users/59fc66a03e54454869460e45/mailboxes/59fc66a03e54454869460e46/messages/1",
* "contentType": {
* "value": "multipart/mixed",
@ -233,7 +237,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true,
flagged: true,
draft: true,
thread: true
thread: true,
flags: true
},
paginatedField: 'idate',
sortAscending
@ -663,7 +668,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true,
flagged: true,
draft: true,
thread: true
thread: true,
flags: true
},
paginatedField: '_id',
sortAscending: false
@ -904,6 +910,7 @@ module.exports = (db, server, messageHandler) => {
undeleted: true,
flagged: true,
draft: true,
flags: true,
attachments: true,
html: true,
text: true,
@ -1028,6 +1035,8 @@ module.exports = (db, server, messageHandler) => {
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,
@ -2439,7 +2448,8 @@ module.exports = (db, server, messageHandler) => {
undeleted: true,
flagged: true,
draft: true,
thread: true
thread: true,
flags: true
},
paginatedField: '_id',
sortAscending
@ -2669,6 +2679,7 @@ module.exports = (db, server, messageHandler) => {
undeleted: true,
flagged: true,
draft: true,
flags: true,
attachments: true,
html: true,
text: true,
@ -2776,6 +2787,8 @@ module.exports = (db, server, messageHandler) => {
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,
@ -3124,7 +3137,9 @@ function formatMessageListing(messageData) {
seen: !messageData.unseen,
deleted: !messageData.undeleted,
flagged: messageData.flagged,
draft: messageData.draft
draft: messageData.draft,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded')
};
let parsedContentType = parsedHeader['content-type'];

View file

@ -76,106 +76,144 @@ module.exports = (db, server, messageHandler, userHandler) => {
return done(null, false);
}
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 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
}
let getMessage = next => {
let updateable = ['reply', 'replyAll', 'forward'];
if (!options.reference || !updateable.includes(options.reference.action)) {
return db.database.collection('messages').findOne(
query,
{
fields: {
'mimeTree.parsedHeader': true,
thread: true
}
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);
},
next
);
}
);
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) => {
@ -288,57 +326,56 @@ module.exports = (db, server, messageHandler, userHandler) => {
let collector = new StreamCollect();
let compiledEnvelope = compiled.getEnvelope();
messageHandler.counters.ttlcounter(
'wdr:' + userData._id.toString(),
compiledEnvelope.to.length,
userData.recipients,
false,
(err, result) => {
if (err) {
err.code = 'ERRREDIS';
return callback(err);
}
let messageId = new ObjectID();
let addToDeliveryQueue = next => {
if (!compiledEnvelope.to || !compiledEnvelope.to.length || options.uploadOnly) {
// no delivery, just build the message
collector.on('data', () => false); //drain
collector.on('end', () => {
next(null, false);
});
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;
let sent = result.value;
let ttl = result.ttl;
let ttlHuman = false;
if (ttl) {
if (ttl < 60) {
ttlHuman = ttl + ' seconds';
} else if (ttl < 3600) {
ttlHuman = Math.round(ttl / 60) + ' minutes';
} else {
ttlHuman = Math.round(ttl / 3600) + ' hours';
messageHandler.counters.ttlcounter(
'wdr:' + userData._id.toString(),
compiledEnvelope.to.length,
userData.recipients,
false,
(err, result) => {
if (err) {
err.code = 'ERRREDIS';
return callback(err);
}
}
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));
}
let success = result.success;
let sent = result.value;
let ttl = result.ttl;
let messageId = new ObjectID();
let addToDeliveryQueue = next => {
if (!compiledEnvelope.to || !compiledEnvelope.to.length || options.uploadOnly) {
// no delivery, just build the message
collector.on('data', () => false); //drain
collector.on('end', () => {
next(null, false);
});
collector.once('error', err => {
next(err);
});
let stream = compiled.createReadStream();
stream.once('error', err => collector.emit('error', err));
stream.pipe(collector);
let ttlHuman = false;
if (ttl) {
if (ttl < 60) {
ttlHuman = ttl + ' seconds';
} else if (ttl < 3600) {
ttlHuman = Math.round(ttl / 60) + ' minutes';
} else {
ttlHuman = Math.round(ttl / 3600) + ' hours';
}
}
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
@ -368,82 +405,94 @@ module.exports = (db, server, messageHandler, userHandler) => {
stream.once('error', err => message.emit('error', err));
stream.pipe(collector).pipe(message);
}
};
}
);
};
addToDeliveryQueue((err, outbound) => {
if (err) {
// ignore
addToDeliveryQueue((err, outbound) => {
if (err) {
// 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');
return callback(null, {
id: false,
mailbox: false,
queueId: outbound,
overQuota: true
let meta = {
source: 'API',
from: compiledEnvelope.from,
to: compiledEnvelope.to,
origin: options.ip,
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
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;
}
let messageOptions = {
user: userData._id,
[options.mailbox ? 'mailbox' : 'specialUse']: options.mailbox
? new ObjectID(options.mailbox)
: options.isDraft ? '\\Drafts' : '\\Sent',
let messageOptions = {
user: userData._id,
[options.mailbox ? 'mailbox' : 'specialUse']: options.mailbox ? new ObjectID(options.mailbox) : '\\Sent',
outbound,
outbound,
meta,
meta: {
source: 'API',
from: compiledEnvelope.from,
to: compiledEnvelope.to,
origin: options.ip,
sess: options.sess,
time: new Date()
},
date: false,
flags: ['\\Seen'].concat(options.isDraft ? '\\Draft' : []),
date: false,
flags: ['\\Seen'],
// always insert drafts, otherwise skip
skipExisting: !options.isDraft
};
// if similar message exists, then skip
skipExisting: true
};
if (raw) {
messageOptions.raw = raw;
} else {
messageOptions.raw = Buffer.concat(collector.chunks, collector.chunklen);
}
if (raw) {
messageOptions.raw = raw;
} else {
messageOptions.raw = Buffer.concat(collector.chunks, collector.chunklen);
}
messageHandler.add(messageOptions, (err, success, info) => {
if (err) {
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
});
});
messageHandler.add(messageOptions, (err, success, info) => {
if (err) {
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
});
});
}
);
});
});
});
});
@ -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} [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} [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 {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
@ -498,6 +548,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
* @apiParam {String} [attachments.filename] Attachment filename
* @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 {Object} [meta] Custom metainfo for the message
* @apiParam {String} [sess] Session identifier for the logs
* @apiParam {String} [ip] IP address for the logs
*
@ -713,6 +764,7 @@ module.exports = (db, server, messageHandler, userHandler) => {
.max(255)
})
),
meta: Joi.object().unknown(true),
sess: Joi.string().max(255),
ip: Joi.string().ip({
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.address Main email address of 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.encryptForwarded If <code>true</code> then forwarded messages are encrypted
* @apiSuccess {Object} results.quota Quota usage limits
@ -216,8 +216,7 @@ module.exports = (db, server, userHandler) => {
address: true,
tags: true,
storageUsed: true,
forward: true,
targetUrl: true,
targets: true,
quota: true,
activated: true,
disabled: true,
@ -260,8 +259,7 @@ module.exports = (db, server, userHandler) => {
name: userData.name,
address: userData.address,
tags: userData.tags || [],
forward: [].concat(userData.forward || []),
targetUrl: userData.targetUrl,
targets: userData.targets && userData.targets.map(t => t.value),
encryptMessages: !!userData.encryptMessages,
encryptForwarded: !!userData.encryptForwarded,
quota: {
@ -302,8 +300,7 @@ module.exports = (db, server, userHandler) => {
* @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} [language] Language code for the User
* @apiParam {String[]} [forward] A list of email addresses to forward all incoming emails
* @apiParam {String} [targetUrl] An URL to post 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 {Number} [quota] Allowed quota of the user in bytes
* @apiParam {Number} [recipients] How many messages per 24 hour can be sent
* @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded
@ -375,8 +372,14 @@ module.exports = (db, server, userHandler) => {
.default(0),
name: Joi.string().max(256),
forward: Joi.array().items(Joi.string().email()),
targetUrl: Joi.string().max(256),
targets: Joi.array().items(
Joi.string().email(),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
quota: Joi.number()
.min(0)
@ -434,8 +437,40 @@ module.exports = (db, server, userHandler) => {
return next();
}
if (result.value.forward) {
result.value.forward = [].concat(result.value.forward || []).map(fwd => tools.normalizeAddress(fwd));
let targets = result.value.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();
}
}
result.value.targets = targets;
}
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.address E-mail address listed in 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} targetUrl An URL to post all incoming emails
* @apiSuccess {String[]} targets List of forwarding targets
* @apiSuccess {Object} limits Account limits and usage
* @apiSuccess {Object} limits.quota Quota usage limits
* @apiSuccess {Number} limits.quota.allowed Allowed quota of the user in bytes
@ -654,8 +688,10 @@ module.exports = (db, server, userHandler) => {
* "encryptForwarded": false,
* "pubKey": "",
* "keyInfo": false,
* "forward": [],
* "targetUrl": "",
* "targets": [
* "my.old.address@example.com",
* "smtp://mx2.zone.eu:25"
* ],
* "limits": {
* "quota": {
* "allowed": 107374182400,
@ -771,8 +807,7 @@ module.exports = (db, server, userHandler) => {
pubKey: userData.pubKey,
keyInfo: getKeyInfo(userData.pubKey),
forward: [].concat(userData.forward || []),
targetUrl: userData.targetUrl,
targets: [].concat(userData.targets || []),
limits: {
quota: {
@ -823,8 +858,7 @@ module.exports = (db, server, userHandler) => {
* @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} [language] Language code for the User
* @apiParam {String[]} [forward] A list of email addresses to forward all incoming emails
* @apiParam {String} [targetUrl] An URL to post 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 {Number} [quota] Allowed quota of the user in bytes
* @apiParam {Number} [recipients] How many messages per 24 hour can be sent
* @apiParam {Number} [forwards] How many messages per 24 hour can be forwarded
@ -884,10 +918,14 @@ module.exports = (db, server, userHandler) => {
name: Joi.string()
.empty('')
.max(256),
forward: Joi.array().items(Joi.string().email()),
targetUrl: Joi.string()
.empty('')
.max(256),
targets: Joi.array().items(
Joi.string().email(),
Joi.string().uri({
scheme: [/smtps?/, /https?/],
allowRelative: false,
relativeOnly: false
})
),
pubKey: Joi.string()
.empty('')
@ -942,12 +980,40 @@ module.exports = (db, server, userHandler) => {
let user = new ObjectID(result.value.user);
if (result.value.forward) {
result.value.forward = [].concat(result.value.forward || []).map(fwd => tools.normalizeAddress(fwd));
}
let targets = result.value.targets;
if (!result.value.targetUrl && 'targetUrl' in req.params) {
result.value.targetUrl = '';
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 (!result.value.name && 'name' in req.params) {

View file

@ -80,8 +80,7 @@ class FilterHandler {
let fields = {
name: true,
forwards: true,
forward: true,
targetUrl: true,
targets: true,
autoreply: true,
encryptMessages: true,
encryptForwarded: true,
@ -266,16 +265,9 @@ class FilterHandler {
// apply matching filter
Object.keys(filter.action).forEach(key => {
if (key === 'forward') {
[].concat(filter.action[key] || []).forEach(address => {
forwardTargets.set(address, { type: 'mail', value: address });
});
return;
}
if (key === 'targetUrl') {
[].concat(filter.action[key] || []).forEach(address => {
forwardTargets.set(address, { type: 'http', value: address });
if (key === 'targets') {
[].concat(filter.action[key] || []).forEach(target => {
forwardTargets.set(target.value, target);
});
return;
}
@ -328,20 +320,13 @@ class FilterHandler {
};
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
(Array.isArray(userData.forward) ? userData.forward : [].concat(userData.forward || [])).forEach(forward => {
if (forward) {
forwardTargets.set(forward, { type: 'mail', value: forward });
}
userData.targets.forEach(target => {
forwardTargets.set(target.value, target);
});
}
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
if (!forwardTargets.size || filterActions.get('spam')) {
return setImmediate(done);

View file

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

View file

@ -76,8 +76,7 @@ const serverOptions = {
{
name: true,
forwards: true,
forward: true,
targetUrl: true,
targets: true,
autoreply: true,
encryptMessages: 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",
"apidoc": "apidoc -i lib/api/ -o docs/"
},
"keywords": ["imap", "mail server"],
"keywords": [
"imap",
"mail server"
],
"author": "Andris Reinman",
"license": "EUPL-1.1+",
"devDependencies": {
@ -40,7 +43,7 @@
"iconv-lite": "0.4.19",
"ioredfour": "1.0.2-ioredis",
"ioredis": "3.2.2",
"joi": "13.1.0",
"joi": "13.1.1",
"js-yaml": "3.10.0",
"key-fingerprint": "1.1.0",
"libbase64": "1.0.2",
@ -53,7 +56,7 @@
"mongodb": "3.0.1",
"mongodb-extended-json": "1.10.0",
"node-forge": "^0.7.1",
"nodemailer": "4.4.1",
"nodemailer": "4.4.2",
"npmlog": "4.1.2",
"openpgp": "2.6.1",
"qrcode": "1.2.0",