mirror of
https://github.com/nodemailer/wildduck.git
synced 2024-09-20 15:26:03 +08:00
added autoreply api
This commit is contained in:
parent
ea4b038f73
commit
044b98b4e9
2
api.js
2
api.js
|
@ -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) {
|
||||
|
|
101
docs/api.md
101
docs/api.md
|
@ -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
|
||||
|
|
12
indexes.yaml
12
indexes.yaml
|
@ -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
156
lib/api/autoreply.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
201
lib/autoreply.js
201
lib/autoreply.js
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
2
lmtp.js
2
lmtp.js
|
@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in a new issue