added autoreply api

This commit is contained in:
Andris Reinman 2017-07-30 18:07:35 +03:00
parent ea4b038f73
commit 044b98b4e9
10 changed files with 387 additions and 99 deletions

2
api.js
View file

@ -19,6 +19,7 @@ const aspsRoutes = require('./lib/api/asps');
const _2faRoutes = require('./lib/api/2fa');
const updatesRoutes = require('./lib/api/updates');
const authRoutes = require('./lib/api/auth');
const autoreplyRoutes = require('./lib/api/autoreply');
const serverOptions = {
name: 'Wild Duck API',
@ -112,6 +113,7 @@ module.exports = done => {
_2faRoutes(db, server, userHandler);
updatesRoutes(db, server, notifier);
authRoutes(db, server, userHandler);
autoreplyRoutes(db, server);
server.on('error', err => {
if (!started) {

View file

@ -1578,6 +1578,107 @@ Response for a successful operation:
}
```
## Autoreplies
Wild Duck supports setting up autoreply messages that are sent to senders by LMTP process.
### Setup Autoreply
#### PUT /users/{user}/autoreply
This call prepares the user to support 2FA tokens. If 2FA is already enabled then this call fails.
**Parameters**
- **user** (required) is the ID of the user
- **status** is a boolean that indicates if autoreply messages should be sent (true) or not (false)
- **subject** is the subject line of autoreply message
- **message** is text body of the autoreply message
**Response fields**
- **success** should be `true`
**Example**
```
curl -XPUT "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/autoreply" -H 'content-type: application/json' -d '{
"status": true,
"subject": "Out of office",
"message": "I'm out of office this week"
}'
```
Response for a successful operation:
```json
{
"success": true
}
```
### Disable Autoreply
#### DELETE /users/{user}/autoreply
You can disable autoreplies either by updating it with status:false or deleting it.
**Parameters**
- **user** (required) is the ID of the user
**Response fields**
- **success** should be `true`
**Example**
```
curl -XDELETE "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/autoreply"
```
Response for a successful operation:
```json
{
"success": true
}
```
### Check Autoreply status
#### GET /users/{user}/autoreply
Return current autoreply status
**Parameters**
- **user** (required) is the ID of the user
**Response fields**
- **success** should be `true`
- **status** is a boolean that indicates if autoreply messages should be sent (true) or not (false)
- **subject** is the subject line of autoreply message
- **message** is text body of the autoreply message
**Example**
```
curl "http://localhost:8080/users/5971da1754cfdc7f0983b2ec/autoreply"
```
Response for a successful operation:
```json
{
"success": true,
"status": true,
"subject": "Out of office",
"message": "I'm out of office this week"
}
```
## Quota
### Recalculate user quota

View file

@ -51,6 +51,7 @@ indexes:
key:
user: 1
_id: -1
- collection: authlog
type: users # index applies to users database
index:
@ -69,11 +70,20 @@ indexes:
key:
user: 1
# Indexes for the autoreply collection
- collection: autoreplies
index:
name: user
key:
user: 1
# Indexes for the mailboxes collection
- collection: mailboxes
index:
name: find_by_user
name: user_path
unique: true
key:
user: 1
path: 1

156
lib/api/autoreply.js Normal file
View file

@ -0,0 +1,156 @@
'use strict';
const Joi = require('joi');
const ObjectID = require('mongodb').ObjectID;
module.exports = (db, server) => {
server.put('/users/:user/autoreply', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required(),
status: Joi.boolean().truthy(['Y', 'true', 'yes', 1]).default(false),
subject: Joi.string().empty('').trim().max(128),
message: Joi.string().empty('').trim().max(10 * 1024)
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
if (!result.value.subject && 'subject' in req.params) {
result.value.subject = '';
}
if (!result.value.message && 'message' in req.params) {
result.value.message = '';
}
let user = (result.value.user = new ObjectID(result.value.user));
db.users.collection('users').updateOne({ _id: user }, { $set: { autoreply: result.value.status } }, (err, r) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!r.matchedCount) {
res.json({
error: 'Unknown user'
});
return next();
}
db.database.collection('autoreplies').updateOne({ user }, { $set: result.value }, { upsert: true }, (err, r) => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success: true,
id: r.insertedId
});
return next();
});
});
});
server.get('/users/:user/autoreply', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
db.database.collection('autoreplies').findOne({ user }, (err, entry) => {
if (err) {
res.json({
error: err.message
});
return next();
}
entry = entry || {};
res.json({
success: true,
status: !!entry.status,
subject: entry.subject || '',
message: entry.message || ''
});
return next();
});
});
server.del('/users/:user/autoreply', (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string().hex().lowercase().length(24).required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message
});
return next();
}
let user = new ObjectID(result.value.user);
db.users.collection('users').updateOne({ _id: user }, { $set: { autoreply: false } }, err => {
if (err) {
res.json({
error: err.message
});
return next();
}
db.database.collection('autoreplies').deleteOne({ user }, err => {
if (err) {
res.json({
error: err.message
});
return next();
}
res.json({
success: true
});
return next();
});
});
});
};

View file

@ -3,121 +3,130 @@
const MailComposer = require('nodemailer/lib/mail-composer');
const MessageSplitter = require('./message-splitter');
const db = require('./db');
const consts = require('./consts');
const maildrop = require('./maildrop');
const MAX_AUTOREPLY_INTERVAL = 4 * 24 * 3600 * 1000;
module.exports = (options, callback) => {
// step 1. check if recipient is valid (non special address)
// step 2. check if recipient not in cache list
// step 3. parse headers, check if not automatic message
// step 4. prepare message with special headers (in-reply-to, references, Auto-Submitted)
if (!options.sender || /mailer-daemon/i.test(options.sender)) {
if (!options.sender || /mailer-daemon|no-?reply/gi.test(options.sender)) {
return callback(null, false);
}
let messageHeaders = false;
let messageSplitter = new MessageSplitter();
messageSplitter.once('headers', headers => {
messageHeaders = headers;
let autoSubmitted = headers.getFirst('Auto-Submitted');
if (autoSubmitted && autoSubmitted.toLowerCase() !== 'no') {
// skip automatic messages
return callback(null, false);
db.database.collection('autoreplies').findOne({ user: options.user._id }, (err, autoreply) => {
if (err) {
return callback(err);
}
let precedence = headers.getFirst('Precedence');
if (precedence && ['list', 'junk', 'bulk'].includes(precedence.toLowerCase())) {
return callback(null, false);
}
let listUnsubscribe = headers.getFirst('List-Unsubscribe');
if (listUnsubscribe) {
return callback(null, false);
}
let suppressAutoresponse = headers.getFirst('X-Auto-Response-Suppress');
if (suppressAutoresponse && /OOF|AutoReply/i.test(suppressAutoresponse)) {
if (!autoreply || !autoreply.status) {
return callback(null, false);
}
db.redis
.multi()
// delete all old entries
.zremrangebyscore('war:' + options.user._id, '-inf', Date.now() - MAX_AUTOREPLY_INTERVAL)
// add enw entry if not present
.zadd('war:' + options.user._id, 'NX', Date.now(), options.sender)
.exec((err, response) => {
if (err) {
return callback(null, false);
}
// step 1. check if recipient is valid (non special address)
// step 2. check if recipient not in cache list
// step 3. parse headers, check if not automatic message
// step 4. prepare message with special headers (in-reply-to, references, Auto-Submitted)
if (!response || !response[1]) {
// already responded
return callback(null, false);
}
let messageHeaders = false;
let messageSplitter = new MessageSplitter();
// check limiting counters
options.messageHandler.counters.ttlcounter('wda:' + options.user._id, 1, 2000, (err, result) => {
if (err || !result.success) {
messageSplitter.once('headers', headers => {
messageHeaders = headers;
let autoSubmitted = headers.getFirst('Auto-Submitted');
if (autoSubmitted && autoSubmitted.toLowerCase() !== 'no') {
// skip automatic messages
return callback(null, false);
}
let precedence = headers.getFirst('Precedence');
if (precedence && ['list', 'junk', 'bulk'].includes(precedence.toLowerCase())) {
return callback(null, false);
}
let listUnsubscribe = headers.getFirst('List-Unsubscribe');
if (listUnsubscribe) {
return callback(null, false);
}
let suppressAutoresponse = headers.getFirst('X-Auto-Response-Suppress');
if (suppressAutoresponse && /OOF|AutoReply/i.test(suppressAutoresponse)) {
return callback(null, false);
}
db.redis
.multi()
// delete all old entries
.zremrangebyscore('war:' + options.user._id, '-inf', Date.now() - consts.MAX_AUTOREPLY_INTERVAL)
// add enw entry if not present
.zadd('war:' + options.user._id, 'NX', Date.now(), options.sender)
.exec((err, response) => {
if (err) {
return callback(null, false);
}
let data = {
envelope: {
from: '',
to: options.sender
},
from: {
name: options.user.name,
address: options.recipient
},
to: options.sender,
subject: options.user.autoreply.subject
? 'Auto: ' + options.user.autoreply.subject
: {
prepared: true,
value: 'Auto: Re: ' + headers.getFirst('Subject')
if (!response || !response[1]) {
// already responded
return callback(null, false);
}
// check limiting counters
options.messageHandler.counters.ttlcounter('wda:' + options.user._id, 1, consts.MAX_AUTOREPLIES, (err, result) => {
if (err || !result.success) {
return callback(null, false);
}
let data = {
envelope: {
from: '',
to: options.sender
},
from: {
name: options.user.name,
address: options.recipient
},
headers: {
'Auto-Submitted': 'auto-replied'
},
inReplyTo: headers.getFirst('Message-ID'),
references: (headers.getFirst('Message-ID') + ' ' + headers.getFirst('References')).trim(),
text: options.user.autoreply.message
};
let compiler = new MailComposer(data);
let message = maildrop(
{
from: '',
to: options.sender,
interface: 'autoreply'
},
callback
);
subject: autoreply.subject
? 'Auto: ' + autoreply.subject
: {
prepared: true,
value: 'Auto: Re: ' + headers.getFirst('Subject')
},
headers: {
'Auto-Submitted': 'auto-replied'
},
inReplyTo: headers.getFirst('Message-ID'),
references: (headers.getFirst('Message-ID') + ' ' + headers.getFirst('References')).trim(),
text: autoreply.message
};
compiler.compile().createReadStream().pipe(message);
let compiler = new MailComposer(data);
let message = maildrop(
{
from: '',
to: options.sender,
interface: 'autoreplies'
},
callback
);
compiler.compile().createReadStream().pipe(message);
});
});
});
});
messageSplitter.on('error', () => false);
messageSplitter.on('data', () => false);
messageSplitter.on('end', () => false);
});
setImmediate(() => {
let pos = 0;
let writeNextChunk = () => {
if (messageHeaders || pos >= options.chunks.length) {
return messageSplitter.end();
}
let chunk = options.chunks[pos++];
if (!messageSplitter.write(chunk)) {
return messageSplitter.once('drain', writeNextChunk);
} else {
setImmediate(writeNextChunk);
}
};
setImmediate(writeNextChunk);
messageSplitter.on('error', () => false);
messageSplitter.on('data', () => false);
messageSplitter.on('end', () => false);
setImmediate(() => {
let pos = 0;
let writeNextChunk = () => {
if (messageHeaders || pos >= options.chunks.length) {
return messageSplitter.end();
}
let chunk = options.chunks[pos++];
if (!messageSplitter.write(chunk)) {
return messageSplitter.once('drain', writeNextChunk);
} else {
setImmediate(writeNextChunk);
}
};
setImmediate(writeNextChunk);
});
});
};

View file

@ -23,5 +23,9 @@ module.exports = {
MAX_PLAINTEXT_CONTENT: 2 * 1024,
// how much HTML content to store. not indexed
MAX_HTML_CONTENT: 300 * 1024
MAX_HTML_CONTENT: 300 * 1024,
MAX_AUTOREPLY_INTERVAL: 4 * 24 * 3600 * 1000,
MAX_AUTOREPLIES: 2000
};

View file

@ -64,6 +64,9 @@ class MailboxHandler {
this.database.collection('mailboxes').insertOne(mailbox, (err, r) => {
if (err) {
if (err.code === 11000) {
return callback(null, 'ALREADYEXISTS');
}
return callback(err);
}
return this.notifier.addEntries(

View file

@ -367,6 +367,9 @@ class UserHandler {
recipients: data.recipients || 0,
forwards: data.forwards || 0,
// autoreply status
autoreply: false,
// default retention for user mailboxes
retention: data.retention || 0,

View file

@ -268,7 +268,7 @@ const serverOptions = {
let sendAutoreply = done => {
// never reply to messages marked as spam
if (!sender || !user.autoreply || !user.autoreply.status || !user.autoreply.message || filterActions.get('spam')) {
if (!sender || !user.autoreply || filterActions.get('spam')) {
return setImmediate(done);
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB