const MailParser = require('mailparser').MailParser; const http = require('http'); const socket_io = require('socket.io'); const express = require('express'); const express_session = require('express-session'); const morgan = require('morgan'); const bodyparser = require('body-parser'); const multer = require('multer'); const rawurlencode = require('rawurlencode'); const SQL = require('./select-builder-pgsql.js'); const MAX_FETCH = 100; class SyncerWeb { constructor(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.enable('trust proxy'); this.app.use(morgan('combined')); 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', (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')); this.app.get('/attachment', wrapAsync(this, 'get_attachment')); this.app.post('/sync', wrapAsync(this, 'post_sync')); this.syncer.events.on('sync', (params) => this.syncer_sync(params)); } listen(port) { this.http.listen(port); } get_auth(req, res) { return res.type('html').send( '
'+ '
' ); } post_auth(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 }); } async get_folders(req, res) { 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'+ ' and (flags @> array[\'pinned\',\'unread\'])) pinned_unread_count'+ ' 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,'+ ' (select count(*) from messages m where m.folder_id=f.id and (flags @> array[\'unread\'])) unread_count'+ ' 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 }); } msgSearchCond(query) { let p = {}; if (query.folderId) { p['m.folder_id'] = query.folderId; } else if (query.folderType == 'unread') { p['(flags @> array[\'unread\'])'] = []; } else if (query.folderType == 'pinned') { p['(flags @> array[\'flagged\'])'] = []; } 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); p['(flags @> array[\'in\'])'] = []; } else if (query.folderType == 'sent') { // Все отправленные p['(flags @> array[\'out\'])'] = []; } else if (query.folderType == 'outbox') { // FIXME это "папка" для локально составленных сообщений, не сохранённых в IMAP } 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()) { p['messages_fulltext(m) @@ plainto_tsquery(?)'] = [ query.search.trim() ]; } if (query.accountId) { p['f.account_id'] = query.accountId; } return Object.keys(p).length ? p : null; } async get_groups(req, res) { 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 }); } async get_messages(req, res) { 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 }); } async get_message(req, res) { 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) { let upd = await this.syncer.fetchFullMessage(msg.account_id, msg.folder_id, msg.folder_name, msg.uid); return res.send({ msg: { ...msg, ...upd[0], props: { ...msg.props, ...upd[0].props } } }); } return res.send({ msg: msg }); } 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(); } syncer_sync(params) { this.io.emit('sync', params); } async post_sync(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' }); } this.syncer.syncAll().catch(console.error); return res.send({ status: 'started' }); } } function wrapAsync(self, fn) { return (req, res) => { self[fn](req, res).catch(e => { console.error(e); res.status(500).send('Internal Error: '+e.stack); }); }; } function ymd(dt) { let m = dt.getMonth()+1; let d = dt.getDate(); return dt.getFullYear()+'-'+(m < 10 ? '0'+m : m)+'-'+(d < 10 ? '0'+d : d); } module.exports = SyncerWeb;