better drafts handling

This commit is contained in:
Andris Reinman 2018-01-23 13:38:49 +02:00
parent c8426f11ca
commit b4023c53ea
4 changed files with 262 additions and 174 deletions

View file

@ -231,6 +231,7 @@ module.exports = (db, server, messageHandler) => {
fields: {
_id: true,
uid: true,
msgid: true,
'meta.from': true,
hdate: true,
subject: true,
@ -239,6 +240,7 @@ module.exports = (db, server, messageHandler) => {
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.sender': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
@ -669,6 +671,7 @@ module.exports = (db, server, messageHandler) => {
fields: {
_id: true,
uid: true,
msgid: true,
mailbox: true,
'meta.from': true,
hdate: true,
@ -678,6 +681,7 @@ module.exports = (db, server, messageHandler) => {
'mimeTree.parsedHeader.to': true,
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
@ -1056,7 +1060,11 @@ module.exports = (db, server, messageHandler) => {
html: messageData.html,
text: messageData.text,
forwardTargets: messageData.forwardTargets,
attachments: messageData.attachments || []
attachments: messageData.attachments || [],
references: (parsedHeader.references || '')
.toString()
.split(/\s+/)
.filter(ref => ref)
};
let parsedContentType = parsedHeader['content-type'];
@ -2458,6 +2466,7 @@ module.exports = (db, server, messageHandler) => {
_id: true,
mailbox: true,
uid: true,
msgid: true,
'meta.from': true,
hdate: true,
subject: true,
@ -2466,6 +2475,7 @@ module.exports = (db, server, messageHandler) => {
'mimeTree.parsedHeader.to': true,
'mimeTree.parsedHeader.cc': true,
'mimeTree.parsedHeader.content-type': true,
'mimeTree.parsedHeader.references': true,
ha: true,
intro: true,
unseen: true,
@ -3163,6 +3173,7 @@ function formatMessageListing(messageData) {
from: from && from[0],
to,
cc,
messageId: messageData.msgid,
subject: messageData.subject,
date: messageData.hdate.toISOString(),
intro: messageData.intro,
@ -3172,7 +3183,11 @@ function formatMessageListing(messageData) {
flagged: messageData.flagged,
draft: messageData.draft,
answered: messageData.flags.includes('\\Answered'),
forwarded: messageData.flags.includes('$Forwarded')
forwarded: messageData.flags.includes('$Forwarded'),
references: (parsedHeader.references || '')
.toString()
.split(/\s+/)
.filter(ref => ref)
};
let parsedContentType = parsedHeader['content-type'];

View file

@ -4,6 +4,7 @@ const config = require('wild-config');
const log = require('npmlog');
const libmime = require('libmime');
const MailComposer = require('nodemailer/lib/mail-composer');
const htmlToText = require('html-to-text');
const Joi = require('../joi');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
@ -321,6 +322,16 @@ module.exports = (db, server, messageHandler, userHandler) => {
attachments: options.attachments || []
};
// ensure plaintext content if html is provided
if (data.html && !data.text) {
try {
// might explode on long or strange strings
data.text = htmlToText.fromString(data.html);
} catch (E) {
// ignore
}
}
let compiler = new MailComposer(data);
let compiled = compiler.compile();
let collector = new StreamCollect();
@ -464,8 +475,8 @@ module.exports = (db, server, messageHandler, userHandler) => {
date: false,
flags: ['\\Seen'].concat(options.isDraft ? '\\Draft' : []),
// always insert drafts, otherwise skip
skipExisting: !options.isDraft
// always insert messages
skipExisting: false
};
if (raw) {
@ -484,11 +495,37 @@ module.exports = (db, server, messageHandler, userHandler) => {
return callback(null, false);
}
return callback(null, {
id: info.uid,
mailbox: info.mailbox,
queueId: outbound
});
let done = () =>
callback(null, {
id: info.uid,
mailbox: info.mailbox,
queueId: outbound
});
if (options.draft) {
return db.database.collection('messages').findOne(
{
mailbox: new ObjectID(options.draft.mailbox),
uid: options.draft.id
},
(err, messageData) => {
if (err || !messageData || messageData.user.toString() !== user.toString()) {
return done();
}
messageHandler.del(
{
user,
mailbox: new ObjectID(options.draft.mailbox),
messageData,
archive: !messageData.flags.includes('\\Draft')
},
done
);
}
);
}
done();
});
}
);
@ -646,6 +683,23 @@ module.exports = (db, server, messageHandler, userHandler) => {
.required()
}),
// if true then treat this message as a draft
isDraft: Joi.boolean()
.empty('')
.truthy(['Y', 'true', 'yes', 'on', 1])
.falsy(['N', 'false', 'no', 'off', 0, ''])
.default(false),
// if set then this message is based on a draft that should be deleted after processing
draft: Joi.object().keys({
mailbox: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
id: Joi.number().required()
}),
uploadOnly: Joi.boolean()
.empty('')
.truthy(['Y', 'true', 'yes', 'on', 1])

View file

@ -430,183 +430,189 @@ class MessageHandler {
};
}
this.database.collection('messages').findOne(
{
mailbox: mailboxData._id,
hdate: messageOpts.hdate,
msgid: messageOpts.msgid,
uid: {
$gt: 0,
$lt: mailboxData.uidNext
}
},
queryOpts,
(err, messageData) => {
if (err) {
return callback(err);
let query = {
mailbox: mailboxData._id,
hdate: messageOpts.hdate,
msgid: messageOpts.msgid,
uid: {
$gt: 0,
$lt: mailboxData.uidNext
}
};
this.database.collection('messages').findOne(query, queryOpts, (err, messageData) => {
if (err) {
return callback(err);
}
if (!messageData) {
// nothing to do here, continue adding message
return callback();
}
let existingId = messageData._id;
let existingUid = messageData.uid;
let existingMailbox = messageData.mailbox;
let outbound = [].concat(messageData.outbound || []).concat(options.outbound || []);
if (outbound) {
messageData.outbound = outbound;
}
if (options.skipExisting) {
// message already exists, just skip it
if (options.outbound) {
// new outbound ID's. update
return this.database.collection('messages').findOneAndUpdate(
{
_id: messageData._id,
mailbox: messageData.mailbox,
uid: messageData.uid
},
{
$addToSet: {
outbound: { $each: [].concat(options.outbound || []) }
}
},
{
returnOriginal: true,
projection: {
_id: true,
outbound: true
}
},
() =>
callback(null, true, {
uid: existingUid,
id: existingId,
mailbox: mailboxData._id,
status: 'skip'
})
);
}
if (!messageData) {
// nothing to do here, continue adding message
return callback();
}
return callback(null, true, {
uid: existingUid,
id: existingId,
mailbox: mailboxData._id,
status: 'skip'
});
}
let existingId = messageData._id;
let existingUid = messageData.uid;
let outbound = [].concat(messageData.outbound || []).concat(options.outbound || []);
if (outbound) {
messageData.outbound = outbound;
}
// As duplicate message was found, update UID, MODSEQ and FLAGS
if (options.skipExisting) {
// message already exists, just skip it
if (options.outbound) {
// new outbound ID's. update
return this.database.collection('messages').findOneAndUpdate(
{
_id: messageData._id,
mailbox: messageData.mailbox,
uid: messageData.uid
},
{
$addToSet: {
outbound: { $each: [].concat(options.outbound || []) }
}
},
{
returnOriginal: true,
projection: {
_id: true,
outbound: true
}
},
() =>
callback(null, true, {
uid: existingUid,
id: existingId,
mailbox: mailboxData._id,
status: 'skip'
})
);
// acquire new UID+MODSEQ
this.database.collection('mailboxes').findOneAndUpdate(
{
_id: mailboxData._id
},
{
$inc: {
// allocate both UID and MODSEQ values so when journal is later sorted by
// modseq then UIDs are always in ascending order
uidNext: 1,
modifyIndex: 1
}
},
{
returnOriginal: true
},
(err, item) => {
if (err) {
return callback(err);
}
return callback(null, true, {
uid: existingUid,
id: existingId,
mailbox: mailboxData._id,
status: 'skip'
});
}
if (!item || !item.value) {
// was not able to acquire a lock
let err = new Error('Mailbox is missing');
err.imapResponse = 'TRYCREATE';
return callback(err);
}
// As duplicate message was found, update UID, MODSEQ and FLAGS
let mailboxData = item.value;
let newUid = mailboxData.uidNext;
let newModseq = mailboxData.modifyIndex + 1;
// acquire new UID+MODSEQ
this.database.collection('mailboxes').findOneAndUpdate(
{
_id: mailboxData._id
},
{
$inc: {
// allocate both UID and MODSEQ values so when journal is later sorted by
// modseq then UIDs are always in ascending order
uidNext: 1,
modifyIndex: 1
}
},
{
returnOriginal: true
},
(err, item) => {
// UID is immutable, so if we want to change it, we need to copy the message
messageData._id = messageOpts.id;
// inserted message might not be in the same mailbox as the deleted one
messageData.mailbox = mailboxData._id;
messageData.uid = newUid;
messageData.modseq = newModseq;
messageData.flags = messageOpts.flags;
messageData.unseen = !messageOpts.flags.includes('\\Seen');
messageData.flagged = messageOpts.flags.includes('\\Flagged');
messageData.undeleted = !messageOpts.flags.includes('\\Deleted');
messageData.draft = messageOpts.flags.includes('\\Draft');
this.database.collection('messages').insertOne(messageData, err => {
if (err) {
return callback(err);
}
if (!item || !item.value) {
// was not able to acquire a lock
let err = new Error('Mailbox is missing');
err.imapResponse = 'TRYCREATE';
return callback(err);
}
let mailboxData = item.value;
let newUid = mailboxData.uidNext;
let newModseq = mailboxData.modifyIndex + 1;
// UID is immutable, so if we want to change it, we need to copy the message
messageData._id = messageOpts.id;
messageData.uid = newUid;
messageData.modseq = newModseq;
messageData.flags = messageOpts.flags;
this.database.collection('messages').insertOne(messageData, err => {
if (err) {
return callback(err);
}
this.database.collection('messages').deleteOne(
{
_id: existingId,
// hash key
mailbox: mailboxData._id,
uid: existingUid
},
err => {
if (err) {
// TODO: how to resolve this? we might end up with two copies of the same message :S
return callback(err);
}
if (options.session && options.session.selected && options.session.selected.mailbox === mailboxData.path) {
options.session.writeStream.write(options.session.formatResponse('EXPUNGE', existingUid));
}
if (options.session && options.session.selected && options.session.selected.mailbox === mailboxData.path) {
options.session.writeStream.write(options.session.formatResponse('EXISTS', messageData.uid));
}
this.notifier.addEntries(
mailboxData,
{
command: 'EXPUNGE',
ignore: options.session && options.session.id,
uid: existingUid,
message: existingId,
unseen: messageData.unseen,
// modseq is needed to avoid updating mailbox entry
modseq: newModseq
},
() => {
this.notifier.addEntries(
mailboxData,
{
command: 'EXISTS',
uid: messageData.uid,
ignore: options.session && options.session.id,
message: messageData._id,
modseq: messageData.modseq,
unseen: messageData.unseen
},
() => {
this.notifier.fire(mailboxData.user);
return callback(null, true, {
uidValidity: mailboxData.uidValidity,
uid: newUid,
id: messageData._id,
mailbox: mailboxData._id,
status: 'update'
});
}
);
}
);
this.database.collection('messages').deleteOne(
{
_id: existingId,
// hash key
mailbox: existingMailbox,
uid: existingUid
},
err => {
if (err) {
// TODO: how to resolve this? we might end up with two copies of the same message :S
return callback(err);
}
);
});
}
);
}
);
if (options.session && options.session.selected && options.session.selected.mailbox.toString() === existingMailbox.toString()) {
options.session.writeStream.write(options.session.formatResponse('EXPUNGE', existingUid));
}
if (options.session && options.session.selected && options.session.selected.mailbox.toString() === mailboxData._id.toString()) {
options.session.writeStream.write(options.session.formatResponse('EXISTS', messageData.uid));
}
this.notifier.addEntries(
existingMailbox.toString() === mailboxData._id.toString() ? mailboxData : existingMailbox,
{
command: 'EXPUNGE',
ignore: options.session && options.session.id,
uid: existingUid,
message: existingId,
unseen: messageData.unseen,
// modseq is needed to avoid updating mailbox entry
modseq: newModseq
},
() => {
this.notifier.addEntries(
mailboxData,
{
command: 'EXISTS',
uid: messageData.uid,
ignore: options.session && options.session.id,
message: messageData._id,
modseq: messageData.modseq,
unseen: messageData.unseen
},
() => {
this.notifier.fire(mailboxData.user);
return callback(null, true, {
uidValidity: mailboxData.uidValidity,
uid: newUid,
id: messageData._id,
mailbox: mailboxData._id,
status: 'update'
});
}
);
}
);
}
);
});
}
);
});
}
updateQuota(mailboxData, inc, callback) {

View file

@ -496,7 +496,20 @@ echo "server {
ssl_certificate /etc/wildduck/certs/fullchain.pem;
ssl_certificate_key /etc/wildduck/certs/privkey.pem;
# special config for EventSource to disable gzip
location /api/events {
proxy_http_version 1.1;
gzip off;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header HOST \$http_host;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://127.0.0.1:3000;
proxy_redirect off;
}
location / {
proxy_http_version 1.1;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header HOST \$http_host;