2016-09-11 22:04:37 +03:00
|
|
|
|
const MailParser = require('mailparser').MailParser;
|
2016-10-03 16:55:09 +03:00
|
|
|
|
const http = require('http');
|
|
|
|
|
const socket_io = require('socket.io');
|
2016-09-11 22:04:37 +03:00
|
|
|
|
const express = require('express');
|
|
|
|
|
const express_session = require('express-session');
|
2019-05-17 16:57:37 +03:00
|
|
|
|
const morgan = require('morgan');
|
2016-09-11 22:04:37 +03:00
|
|
|
|
const bodyparser = require('body-parser');
|
|
|
|
|
const multer = require('multer');
|
2019-05-19 15:25:14 +03:00
|
|
|
|
const rawurlencode = require('rawurlencode');
|
2016-09-11 22:04:37 +03:00
|
|
|
|
|
2019-05-08 16:39:14 +03:00
|
|
|
|
const SQL = require('./select-builder-pgsql.js');
|
|
|
|
|
|
2016-10-07 14:39:07 +03:00
|
|
|
|
const MAX_FETCH = 100;
|
|
|
|
|
|
2019-05-08 16:39:14 +03:00
|
|
|
|
class SyncerWeb
|
2016-09-11 22:04:37 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
constructor(syncer, pg, cfg)
|
2016-09-11 22:04:37 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
this.syncer = syncer;
|
|
|
|
|
this.pg = pg;
|
|
|
|
|
this.cfg = cfg;
|
|
|
|
|
this.app = express();
|
|
|
|
|
this.http = http.Server(this.app);
|
|
|
|
|
this.io = socket_io(this.http);
|
2019-05-17 16:57:37 +03:00
|
|
|
|
this.app.enable('trust proxy');
|
|
|
|
|
this.app.use(morgan('combined'));
|
2019-05-08 16:39:14 +03:00
|
|
|
|
this.app.use(bodyparser.urlencoded({ extended: false }));
|
|
|
|
|
this.app.use(express_session({
|
|
|
|
|
secret: this.cfg.sessionSecret || '1083581xm1l3s1l39k',
|
|
|
|
|
resave: false,
|
|
|
|
|
saveUninitialized: false
|
|
|
|
|
}));
|
2019-05-10 01:26:31 +03:00
|
|
|
|
this.app.get('/auth', (req, res) => this.get_auth(req, res));
|
|
|
|
|
this.app.post('/auth', (req, res) => this.post_auth(req, res));
|
|
|
|
|
this.app.get('/folders', wrapAsync(this, 'get_folders'));
|
|
|
|
|
this.app.get('/groups', wrapAsync(this, 'get_groups'));
|
|
|
|
|
this.app.get('/messages', wrapAsync(this, 'get_messages'));
|
|
|
|
|
this.app.get('/message', wrapAsync(this, 'get_message'));
|
2019-05-19 15:25:14 +03:00
|
|
|
|
this.app.get('/attachment', wrapAsync(this, 'get_attachment'));
|
2019-05-10 01:26:31 +03:00
|
|
|
|
this.app.post('/sync', wrapAsync(this, 'post_sync'));
|
|
|
|
|
this.syncer.events.on('sync', (params) => this.syncer_sync(params));
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-08 16:39:14 +03:00
|
|
|
|
listen(port)
|
2016-09-11 22:04:37 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
this.http.listen(port);
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
get_auth(req, res)
|
2016-09-11 22:04:37 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
return res.type('html').send(
|
|
|
|
|
'<form action="/auth" method="post"><input name="login" />'+
|
|
|
|
|
' <input name="password" type="password" /> <input type="submit" /></form>'
|
|
|
|
|
);
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
post_auth(req, res)
|
2016-10-09 18:52:28 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (!req.body)
|
|
|
|
|
return res.sendStatus(400);
|
|
|
|
|
if (req.body.login == this.cfg.login && req.body.password == this.cfg.password)
|
2016-10-09 18:52:28 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
req.session.auth = true;
|
|
|
|
|
return res.send({ ok: true });
|
2016-10-09 18:52:28 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
return res.send({ ok: false });
|
2016-10-09 18:52:28 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
async get_folders(req, res)
|
2016-10-09 17:48:47 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
const accounts = (await this.pg.query(
|
|
|
|
|
'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'+
|
2019-05-16 21:29:58 +03:00
|
|
|
|
' and (flags @> array[\'pinned\',\'unread\'])) pinned_unread_count'+
|
2019-05-08 16:39:14 +03:00
|
|
|
|
' from accounts a'
|
|
|
|
|
)).rows;
|
|
|
|
|
const folders = (await this.pg.query(
|
|
|
|
|
'select id, account_id, name,'+
|
|
|
|
|
' (select count(*) from messages m where m.folder_id=f.id) total_count,'+
|
2019-05-16 21:29:58 +03:00
|
|
|
|
' (select count(*) from messages m where m.folder_id=f.id and (flags @> array[\'unread\'])) unread_count'+
|
2019-05-08 16:39:14 +03:00
|
|
|
|
' from folders f order by account_id, name'
|
|
|
|
|
)).rows;
|
|
|
|
|
let 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 });
|
2016-10-09 17:48:47 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
|
|
|
|
msgSearchCond(query)
|
2016-10-09 17:48:47 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
let p = {};
|
|
|
|
|
if (query.folderId)
|
|
|
|
|
{
|
|
|
|
|
p['m.folder_id'] = query.folderId;
|
|
|
|
|
}
|
|
|
|
|
else if (query.folderType == 'unread')
|
|
|
|
|
{
|
2019-05-16 21:29:58 +03:00
|
|
|
|
p['(flags @> array[\'unread\'])'] = [];
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
|
|
|
|
else if (query.folderType == 'pinned')
|
|
|
|
|
{
|
2019-05-16 21:29:58 +03:00
|
|
|
|
p['(flags @> array[\'flagged\'])'] = [];
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
|
|
|
|
else if (query.folderType == 'inbox')
|
|
|
|
|
{
|
|
|
|
|
let folders = Object.keys(this.syncer.accounts)
|
|
|
|
|
.map(id => [ id, this.syncer.accounts[id].settings.folders.spam ])
|
|
|
|
|
.filter(f => f[1]);
|
|
|
|
|
p['(f.account_id, f.name) NOT IN ('+folders.map(f => '(?, ?)').join(', ')+')'] =
|
|
|
|
|
[].concat.apply([], folders);
|
2019-05-16 21:29:58 +03:00
|
|
|
|
p['(flags @> array[\'in\'])'] = [];
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
2019-05-16 21:29:58 +03:00
|
|
|
|
else if (query.folderType == 'sent')
|
2016-10-09 17:48:47 +03:00
|
|
|
|
{
|
2019-05-16 21:29:58 +03:00
|
|
|
|
// Все отправленные
|
|
|
|
|
p['(flags @> array[\'out\'])'] = [];
|
2016-10-09 17:48:47 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
else if (query.folderType == 'outbox')
|
|
|
|
|
{
|
2019-05-16 21:29:58 +03:00
|
|
|
|
// FIXME это "папка" для локально составленных сообщений, не сохранённых в IMAP
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
|
|
|
|
else if (query.folderType == 'drafts' || query.folderType == 'spam' || query.folderType == 'trash')
|
|
|
|
|
{
|
|
|
|
|
let folders = Object.keys(this.syncer.accounts)
|
|
|
|
|
.map(id => [ id, this.syncer.accounts[id].settings.folders[query.folderType] ])
|
|
|
|
|
.filter(f => f[1]);
|
|
|
|
|
p['(f.account_id, f.name) IN ('+folders.map(f => '(?, ?)').join(', ')+')'] =
|
|
|
|
|
[].concat.apply([], folders);
|
|
|
|
|
}
|
|
|
|
|
if (typeof query.search == 'string' && query.search.trim())
|
|
|
|
|
{
|
2019-05-14 13:54:56 +03:00
|
|
|
|
p['messages_fulltext(m) @@ plainto_tsquery(?)'] = [ query.search.trim() ];
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
|
|
|
|
if (query.accountId)
|
|
|
|
|
{
|
|
|
|
|
p['f.account_id'] = query.accountId;
|
|
|
|
|
}
|
|
|
|
|
return Object.keys(p).length ? p : null;
|
2016-10-09 17:48:47 +03:00
|
|
|
|
}
|
2016-10-09 02:01:04 +03:00
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
async get_groups(req, res)
|
2016-10-07 02:06:31 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
const cond = this.msgSearchCond(req.query);
|
|
|
|
|
if (!cond)
|
|
|
|
|
{
|
|
|
|
|
return res.status(500).send('Need message query parameters');
|
|
|
|
|
}
|
|
|
|
|
let intervals = [];
|
|
|
|
|
let today, today_ts;
|
|
|
|
|
today = new Date(ymd(new Date()));
|
|
|
|
|
today_ts = today.getTime();
|
|
|
|
|
let week_start = today_ts - ((today.getDay()+6)%7)*86400000;
|
|
|
|
|
let prev_week = ymd(new Date(week_start - 86400000*7));
|
|
|
|
|
for (let i = 1; i <= 12; i++)
|
|
|
|
|
{
|
|
|
|
|
let 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 (let i = week_start, d = 1; i < today_ts; i += 86400000, d++)
|
|
|
|
|
{
|
|
|
|
|
intervals.push({ date: ymd(new Date(i)), name: 'd'+d });
|
|
|
|
|
}
|
|
|
|
|
for (let i = today.getFullYear()-1; i >= 1970; i--)
|
|
|
|
|
{
|
|
|
|
|
intervals.unshift({ date: i+'-01-01', name: ''+i });
|
|
|
|
|
}
|
|
|
|
|
intervals.push({ date: ymd(today), name: 't' });
|
|
|
|
|
for (let 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 <)
|
|
|
|
|
let groups = await SQL.select(
|
|
|
|
|
this.pg,
|
|
|
|
|
{ d: SQL.values(intervals) },
|
|
|
|
|
'd.name, d.date, ('+SQL.select_builder(
|
|
|
|
|
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
|
|
|
|
|
'count(*) count',
|
|
|
|
|
{ ...cond, 'm.time >= d.date::date and m.time < d.date_end::date': [] },
|
|
|
|
|
)+') count',
|
|
|
|
|
[],
|
|
|
|
|
{ order_by: 'date desc' }
|
|
|
|
|
);
|
|
|
|
|
groups = groups.filter(g => g.count > 0);
|
|
|
|
|
return res.send({ groups: groups });
|
2016-10-07 02:06:31 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
async get_messages(req, res)
|
2016-10-07 02:06:31 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
let cond = this.msgSearchCond(req.query);
|
|
|
|
|
if (!cond)
|
|
|
|
|
{
|
|
|
|
|
return res.status(500).send('Need message query parameters');
|
|
|
|
|
}
|
|
|
|
|
let limit = req.query.limit || 50;
|
|
|
|
|
if (limit > MAX_FETCH)
|
|
|
|
|
limit = MAX_FETCH;
|
|
|
|
|
let offset = req.query.offset || 0;
|
|
|
|
|
let msgs = await SQL.select(
|
|
|
|
|
this.pg,
|
|
|
|
|
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
|
|
|
|
|
'm.*', cond,
|
|
|
|
|
{ order_by: 'time desc', limit, offset }
|
|
|
|
|
);
|
|
|
|
|
for (let i = 0; i < msgs.length; i++)
|
|
|
|
|
{
|
|
|
|
|
delete msgs[i].text_index;
|
|
|
|
|
}
|
|
|
|
|
return res.send({ messages: msgs });
|
2016-10-07 02:06:31 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
async get_message(req, res)
|
2016-10-09 18:52:28 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
let msgId = req.query.msgId;
|
|
|
|
|
let msg = await SQL.select(
|
|
|
|
|
this.pg,
|
|
|
|
|
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
|
|
|
|
|
'm.*, f.name folder_name, f.account_id',
|
|
|
|
|
{ 'm.id': msgId }, null, SQL.MS_ROW
|
|
|
|
|
);
|
|
|
|
|
delete msg.text_index;
|
|
|
|
|
if (!msg)
|
|
|
|
|
{
|
|
|
|
|
return res.send({ error: 'not-found' });
|
|
|
|
|
}
|
|
|
|
|
if (!msg.body_html && !msg.body_text)
|
|
|
|
|
{
|
2019-05-17 16:57:37 +03:00
|
|
|
|
let upd = await this.syncer.fetchFullMessage(msg.account_id, msg.folder_id, msg.folder_name, msg.uid);
|
2019-05-18 03:01:13 +03:00
|
|
|
|
return res.send({ msg: { ...msg, ...upd[0], props: { ...msg.props, ...upd[0].props } } });
|
2019-05-08 16:39:14 +03:00
|
|
|
|
}
|
|
|
|
|
return res.send({ msg: msg });
|
2016-10-09 18:52:28 +03:00
|
|
|
|
}
|
2019-05-08 16:39:14 +03:00
|
|
|
|
|
2019-05-19 15:25:14 +03:00
|
|
|
|
async get_attachment(req, res)
|
|
|
|
|
{
|
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
let msgId = req.query.msgId;
|
|
|
|
|
let sha1 = (req.query.sha1||'').replace(/[^0-9a-fA-F]+/g, '').toLowerCase();
|
|
|
|
|
if (!msgId || !sha1)
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(404);
|
|
|
|
|
}
|
|
|
|
|
let msg = await SQL.select(
|
|
|
|
|
this.pg, 'messages', '*', { id: msgId }, null, SQL.MS_ROW
|
|
|
|
|
);
|
|
|
|
|
let a = ((msg.props||{}).attachments||[]).filter(a => a.sha1 == sha1)[0];
|
|
|
|
|
if (!a)
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(404);
|
|
|
|
|
}
|
|
|
|
|
if (new RegExp(
|
|
|
|
|
// HTML may contain cookie-stealing JavaScript and web bugs
|
|
|
|
|
'^text/(html|(x-)?javascript)$|^application/x-shellscript$'+
|
|
|
|
|
// PHP/Perl/Bash/etc scripts may execute arbitrary code on the server
|
|
|
|
|
'|php|perl|python|bash|x-c?sh(e|$)'+
|
|
|
|
|
// Client-side hazards on Internet Explorer
|
|
|
|
|
'|^text/scriptlet$|^application/x-msdownload$'+
|
|
|
|
|
// Windows metafile, client-side vulnerability on some systems
|
|
|
|
|
'|^application/x-msmetafile$', 'is'
|
|
|
|
|
).exec(a.mimetype))
|
|
|
|
|
{
|
|
|
|
|
a.mimetype = 'application/octet-stream';
|
|
|
|
|
}
|
|
|
|
|
res.set('Content-Type', a.mimetype);
|
|
|
|
|
res.set('Content-Disposition', 'attachment; filename*=UTF-8\'\''+rawurlencode(a.name));
|
|
|
|
|
await new Promise((r, j) => res.sendFile(this.syncer.files_path+'/'+sha1.substr(0, 2)+'/'+sha1.substr(2, 2)+'/'+sha1+'.bin', r));
|
|
|
|
|
res.end();
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
syncer_sync(params)
|
2016-10-07 02:06:31 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
this.io.emit('sync', params);
|
2016-10-07 02:06:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-10 01:26:31 +03:00
|
|
|
|
async post_sync(req, res)
|
2016-10-02 21:57:43 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
if (this.cfg.login && (!req.session || !req.session.auth))
|
|
|
|
|
{
|
|
|
|
|
return res.sendStatus(401);
|
|
|
|
|
}
|
|
|
|
|
if (this.syncer.syncInProgress)
|
|
|
|
|
{
|
|
|
|
|
return res.send({ error: 'already-running' });
|
|
|
|
|
}
|
|
|
|
|
this.syncer.syncAll().catch(console.error);
|
|
|
|
|
return res.send({ status: 'started' });
|
2016-10-02 21:57:43 +03:00
|
|
|
|
}
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-17 16:57:37 +03:00
|
|
|
|
function wrapAsync(self, fn)
|
2016-10-03 13:00:56 +03:00
|
|
|
|
{
|
2019-05-17 16:57:37 +03:00
|
|
|
|
return (req, res) =>
|
2016-10-03 13:00:56 +03:00
|
|
|
|
{
|
2019-05-17 16:57:37 +03:00
|
|
|
|
self[fn](req, res).catch(e =>
|
2016-10-03 13:00:56 +03:00
|
|
|
|
{
|
2019-05-17 16:57:37 +03:00
|
|
|
|
console.error(e);
|
|
|
|
|
res.status(500).send('Internal Error: '+e.stack);
|
|
|
|
|
});
|
|
|
|
|
};
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-08 16:39:14 +03:00
|
|
|
|
function ymd(dt)
|
2016-09-11 22:04:37 +03:00
|
|
|
|
{
|
2019-05-08 16:39:14 +03:00
|
|
|
|
let m = dt.getMonth()+1;
|
|
|
|
|
let d = dt.getDate();
|
|
|
|
|
return dt.getFullYear()+'-'+(m < 10 ? '0'+m : m)+'-'+(d < 10 ? '0'+d : d);
|
2016-09-11 22:04:37 +03:00
|
|
|
|
}
|
2019-05-10 01:26:31 +03:00
|
|
|
|
|
|
|
|
|
module.exports = SyncerWeb;
|