337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
const gen = require('gen-thread');
|
||
const MailParser = require('mailparser').MailParser;
|
||
const htmlawed = require('htmlawed');
|
||
|
||
const http = require('http');
|
||
const socket_io = require('socket.io');
|
||
const express = require('express');
|
||
const express_session = require('express-session');
|
||
const bodyparser = require('body-parser');
|
||
const multer = require('multer');
|
||
|
||
const css = require('css');
|
||
|
||
const MAX_FETCH = 100;
|
||
|
||
module.exports = SyncerWeb;
|
||
|
||
function SyncerWeb(syncer, pg, cfg)
|
||
{
|
||
this.syncer = syncer;
|
||
this.pg = pg;
|
||
this.cfg = cfg;
|
||
this.app = express();
|
||
this.http = http.Server(this.app);
|
||
this.io = socket_io(this.http);
|
||
this.app.use(bodyparser.urlencoded({ extended: false }));
|
||
this.app.use(express_session({
|
||
secret: this.cfg.sessionSecret || '1083581xm1l3s1l39k',
|
||
resave: false,
|
||
saveUninitialized: false
|
||
}));
|
||
this.app.get('/auth', this.get_auth);
|
||
this.app.post('/auth', this.post_auth);
|
||
this.app.get('/folders', genRequest(this.get_folders.bind(this)));
|
||
this.app.get('/groups', genRequest(this.get_groups.bind(this)));
|
||
this.app.get('/messages', genRequest(this.get_messages.bind(this)));
|
||
this.app.get('/message', genRequest(this.get_message.bind(this)));
|
||
this.app.post('/sync', genRequest(this.post_sync.bind(this)));
|
||
this.syncer.events.on('sync', this.syncer_sync.bind(this));
|
||
}
|
||
|
||
SyncerWeb.prototype.listen = function(port)
|
||
{
|
||
this.http.listen(port);
|
||
}
|
||
|
||
SyncerWeb.prototype.get_auth = function(req, res)
|
||
{
|
||
return res.type('html').send(
|
||
'<form action="/auth" method="post"><input name="login" />'+
|
||
' <input name="password" type="password" /> <input type="submit" /></form>'
|
||
);
|
||
}
|
||
|
||
SyncerWeb.prototype.post_auth = function(req, res)
|
||
{
|
||
if (!req.body)
|
||
return res.sendStatus(400);
|
||
if (req.body.login == this.cfg.login && req.body.password == this.cfg.password)
|
||
{
|
||
req.session.auth = true;
|
||
return res.send({ ok: true });
|
||
}
|
||
return res.send({ ok: false });
|
||
}
|
||
|
||
SyncerWeb.prototype.get_folders = function*(req, res)
|
||
{
|
||
if (this.cfg.login && (!req.session || !req.session.auth))
|
||
{
|
||
return res.sendStatus(401);
|
||
}
|
||
var [ accounts ] = yield this.pg.select(
|
||
'id, name, email, settings->\'folders\' folderMap,'+
|
||
' (select count(*) from messages m, folders f where m.folder_id=f.id and f.account_id=a.id and (flags @> array[\'pinned\',\'unread\']::varchar(255)[])) pinned_unread_count'
|
||
).from('accounts a').rows(gen.ef());
|
||
var [ folders ] = yield this.pg.select(
|
||
'id, account_id, name,'+
|
||
' (select count(*) from messages m where m.folder_id=f.id) total_count,'+
|
||
' (select count(*) from messages m where m.folder_id=f.id and (flags @> array[\'unread\']::varchar(255)[])) unread_count'
|
||
).from('folders f').orderBy('account_id, name').rows(gen.ef());
|
||
var fh = {};
|
||
for (let i = 0; i < folders.length; i++)
|
||
{
|
||
fh[folders[i].account_id] = fh[folders[i].account_id] || [];
|
||
fh[folders[i].account_id].push(folders[i]);
|
||
}
|
||
for (let i = 0; i < accounts.length; i++)
|
||
{
|
||
accounts[i].folders = fh[accounts[i].id] || [];
|
||
}
|
||
return res.send({ accounts: accounts });
|
||
}
|
||
|
||
function ymd(dt)
|
||
{
|
||
var m = dt.getMonth()+1;
|
||
var d = dt.getDate();
|
||
return dt.getFullYear()+'-'+(m < 10 ? '0'+m : m)+'-'+(d < 10 ? '0'+d : d);
|
||
}
|
||
|
||
SyncerWeb.prototype.msgSearchCond = function(query)
|
||
{
|
||
var p = [];
|
||
if (query.folderId)
|
||
p.push(this.pg.sql.eq('m.folder_id', query.folderId));
|
||
else if (query.folderType == 'unread')
|
||
p.push(this.pg.sql('(flags @> array[\'unread\']::varchar(255)[])'));
|
||
else if (query.folderType == 'pinned')
|
||
p.push(this.pg.sql('(flags @> array[\'flagged\']::varchar(255)[])'));
|
||
else if (query.folderType == 'inbox')
|
||
{
|
||
var folders = [];
|
||
for (var id in this.syncer.accounts)
|
||
{
|
||
n = this.syncer.accounts[id].settings.folders.spam;
|
||
if (n)
|
||
folders.push(this.pg.sql.and({ 'f.name': n, 'f.account_id': id }));
|
||
}
|
||
p.push(this.pg.sql.not(this.pg.sql.or.apply(this.pg.sql, folders)));
|
||
p.push(this.pg.sql('(flags @> array[\'in\']::varchar(255)[])'));
|
||
}
|
||
else if (query.folderType == 'out')
|
||
p.push(this.pg.sql('(flags @> array[\'out\']::varchar(255)[])'));
|
||
else if (query.folderType == 'outbox')
|
||
{
|
||
// TODO это какая-то хитрая метапапка, которая не живёт на IMAP'е?
|
||
}
|
||
else if (query.folderType == 'drafts' || query.folderType == 'spam' || query.folderType == 'trash')
|
||
{
|
||
var folders = [];
|
||
var n;
|
||
for (var id in this.syncer.accounts)
|
||
{
|
||
n = this.syncer.accounts[id].settings.folders[query.folderType];
|
||
if (n)
|
||
folders.push(this.pg.sql.and({ 'f.name': n, 'f.account_id': id }));
|
||
}
|
||
p.push(this.pg.sql.or.apply(this.pg.sql, folders));
|
||
}
|
||
if (typeof query.search == 'string' && query.search.trim())
|
||
p.push(this.pg.sql('messages_fulltext(m) @@ plainto_tsquery($1)', query.search.trim()));
|
||
if (query.accountId)
|
||
p.push(this.pg.sql.and(p, this.pg.sql.eq('f.account_id', query.accountId)));
|
||
return p.length ? this.pg.sql.and.apply(this.pg.sql, p) : null;
|
||
}
|
||
|
||
SyncerWeb.prototype.get_groups = function*(req, res)
|
||
{
|
||
if (this.cfg.login && (!req.session || !req.session.auth))
|
||
return res.sendStatus(401);
|
||
var cond = this.msgSearchCond(req.query);
|
||
if (!cond)
|
||
return res.status(500).send('Need message query parameters');
|
||
var intervals = [];
|
||
var today, today_ts;
|
||
today = new Date(ymd(new Date()));
|
||
today_ts = today.getTime();
|
||
var week_start = today_ts - ((today.getDay()+6)%7)*86400000;
|
||
var prev_week = ymd(new Date(week_start - 86400000*7));
|
||
for (var i = 1; i <= 12; i++)
|
||
{
|
||
var d = today.getFullYear()+'-'+(i < 10 ? '0' : '')+i+'-01';
|
||
if (d >= prev_week)
|
||
break;
|
||
intervals.push({ date: d, name: 'm'+i });
|
||
}
|
||
intervals.push({ date: prev_week, name: 'pw' });
|
||
for (var i = week_start, d = 1; i < today_ts; i += 86400000, d++)
|
||
{
|
||
intervals.push({ date: ymd(new Date(i)), name: 'd'+d });
|
||
}
|
||
for (var i = today.getFullYear()-1; i >= 1970; i--)
|
||
{
|
||
intervals.unshift({ date: i+'-01-01', name: ''+i });
|
||
}
|
||
intervals.push({ date: ymd(today), name: 't' });
|
||
for (var i = 0; i < intervals.length-1; i++)
|
||
{
|
||
intervals[i].date_end = intervals[i+1].date;
|
||
}
|
||
intervals[intervals.length-1].date_end = '100000-12-31'; // it's faster than (is null or <)
|
||
var [ groups ] = yield this.pg
|
||
.select('d.name, d.date, ('+
|
||
this.pg.select('count(*)')
|
||
.from('messages m')
|
||
.innerJoin('folders f', this.pg.sql('f.id=m.folder_id'))
|
||
.where(cond)
|
||
.where(this.pg.sql('m.time >= d.date::date and m.time < d.date_end::date'))
|
||
+') count')
|
||
.from(this.pg.sql.values(intervals).as('d').columns())
|
||
.orderBy('date desc').rows(gen.ef());
|
||
groups = groups.filter(g => g.count > 0);
|
||
return res.send({ groups: groups });
|
||
}
|
||
|
||
SyncerWeb.prototype.get_messages = function*(req, res)
|
||
{
|
||
if (this.cfg.login && (!req.session || !req.session.auth))
|
||
return res.sendStatus(401);
|
||
var cond = this.msgSearchCond(req.query);
|
||
if (!cond)
|
||
return res.status(500).send('Need message query parameters');
|
||
var limit = req.query.limit || 50;
|
||
if (limit > MAX_FETCH)
|
||
limit = MAX_FETCH;
|
||
var offset = req.query.offset || 0;
|
||
var [ msgs ] = yield this.pg.select('m.*').from('messages m')
|
||
.innerJoin('folders f', this.pg.sql('f.id=m.folder_id'))
|
||
.where(cond).orderBy('time desc').limit(limit).offset(offset)
|
||
.rows(gen.ef());
|
||
for (var i = 0; i < msgs.length; i++)
|
||
{
|
||
delete msgs[i].text_index;
|
||
}
|
||
return res.send({ messages: msgs });
|
||
}
|
||
|
||
SyncerWeb.prototype.get_message = function*(req, res)
|
||
{
|
||
if (this.cfg.login && (!req.session || !req.session.auth))
|
||
return res.sendStatus(401);
|
||
var msgId = req.query.msgId;
|
||
console.log('fetch message '+msgId);
|
||
var [ msg ] = yield this.pg.select('m.*, f.name folder_name, f.account_id')
|
||
.from('messages m').join('folders f', this.pg.sql('f.id=m.folder_id'))
|
||
.where({ 'm.id': msgId }).rows(gen.ef());
|
||
if (!msg.length)
|
||
return res.send({ error: 'not-found' });
|
||
msg = msg[0];
|
||
if (!msg.body_html && !msg.body_text)
|
||
{
|
||
var srv = yield* this.syncer.imap.getConnection(msg.account_id, msg.folder_name);
|
||
var [ upd ] = yield* this.syncer.imap.runFetch(
|
||
srv, msg.uid, { bodies: '' },
|
||
(messages, state) => this.getBody(messages, msg.folder_id)
|
||
);
|
||
this.syncer.imap.releaseConnection(msg.account_id);
|
||
return res.send({ msg: { ...msg, ...upd } });
|
||
}
|
||
return res.send({ msg: msg });
|
||
}
|
||
|
||
SyncerWeb.prototype.syncer_sync = function(params)
|
||
{
|
||
this.io.emit('sync', params);
|
||
}
|
||
|
||
SyncerWeb.prototype.post_sync = function*(req, res)
|
||
{
|
||
if (this.cfg.login && (!req.session || !req.session.auth))
|
||
return res.sendStatus(401);
|
||
if (this.syncer.syncInProgress)
|
||
return res.send({ error: 'already-running' });
|
||
gen.run(this.syncer.syncAll());
|
||
return res.send({ status: 'started' });
|
||
}
|
||
|
||
function rewriteCss(ast)
|
||
{
|
||
var rules = ast.rules || ast.stylesheet && ast.stylesheet.rules;
|
||
if (ast.stylesheet && ast.stylesheet.parsingErrors)
|
||
{
|
||
delete ast.stylesheet.parsingErrors;
|
||
}
|
||
if (rules)
|
||
{
|
||
for (var i = 0; i < rules.length; i++)
|
||
{
|
||
if (rules[i].type == 'document')
|
||
{
|
||
// prune @document instructions (may spy on current URL)
|
||
rules.splice(i--, 1);
|
||
}
|
||
else if (rules[i].type == 'rule' && (!rules[i].selectors || !rules[i].declarations))
|
||
rules.splice(i--, 1);
|
||
else
|
||
rewriteCss(rules[i]);
|
||
}
|
||
}
|
||
else if (ast.type == 'rule')
|
||
{
|
||
for (var i = 0; i < ast.selectors.length; i++)
|
||
{
|
||
// FIXME: Do not hardcode css selector for frontend here
|
||
// This will require generating unique substitution string,
|
||
// so we may also generate 'blocked images' stubs when we do it.
|
||
ast.selectors[i] = '.message-view .text '+ast.selectors[i];
|
||
}
|
||
}
|
||
}
|
||
|
||
function sanitizeHtml(html)
|
||
{
|
||
let styles = '';
|
||
html = (html||'').replace(/<style[^<>]*>([\s\S]*?)<\/style\s*>/ig, function(m, m1)
|
||
{
|
||
styles += m1+'\n';
|
||
return '';
|
||
});
|
||
html = html.replace(/^[\s\S]*?<body[^<>]*>([\s\S]*)<\/body>[\s\S]*$/i, '$1');
|
||
html = html.replace(/^[\s\S]*?<html[^<>]*>([\s\S]*)<\/html>[\s\S]*$/i, '$1');
|
||
if (styles)
|
||
{
|
||
html = '<style>\n'+styles+'</style>\n'+html;
|
||
styles = '';
|
||
}
|
||
html = htmlawed.sanitize(html||'', { safe: 1, elements: '* +style', keep_bad: 0, comment: 1 });
|
||
html = html.replace(/<style[^>]*>([\s\S]*)<\/style\s*>/ig, function(m, m1)
|
||
{
|
||
var ast = css.parse(m1, { silent: true });
|
||
rewriteCss(ast);
|
||
return '<style>'+css.stringify(ast)+'</style>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
SyncerWeb.prototype.getBody = function*(messages, boxId)
|
||
{
|
||
for (var i = 0; i < messages.length; i++)
|
||
{
|
||
let msg = messages[i];
|
||
let obj = yield* this.syncer.parseMsg(msg[0].headers);
|
||
obj.html = sanitizeHtml(obj.html);
|
||
let upd = { body_text: obj.text||'', body_html: obj.html };
|
||
upd.body_html_text = obj.html.replace(/<style[^>]*>.*<\/style\s*>|<\/?[^>]*>/g, '');
|
||
yield this.pg.update('messages m', upd).where({ folder_id: boxId, uid: msg[0].uid }).run(gen.ef());
|
||
if (messages.length == 1)
|
||
return [ upd ];
|
||
}
|
||
}
|
||
|
||
function genRequest(fn)
|
||
{
|
||
return (req, res) => gen.run(fn(req, res), null, e => res.status(500).send('Internal Error: '+e.stack));
|
||
}
|