From ad5f2f0e6f7283c6a459c0a2a67bbfc00cc3ad44 Mon Sep 17 00:00:00 2001 From: Aleksander Machniak Date: Mon, 7 Jan 2019 21:39:09 +0100 Subject: [PATCH] Push plugin (#6544) --- plugins/push/README | 47 ++++ plugins/push/composer.json | 24 ++ plugins/push/config.inc.php.dist | 41 ++++ plugins/push/lib/cache.php | 85 +++++++ plugins/push/lib/parser_notify.php | 230 +++++++++++++++++++ plugins/push/lib/service.php | 233 +++++++++++++++++++ plugins/push/push.js | 347 +++++++++++++++++++++++++++++ plugins/push/push.php | 62 ++++++ plugins/push/server.php | 34 +++ 9 files changed, 1103 insertions(+) create mode 100644 plugins/push/README create mode 100644 plugins/push/composer.json create mode 100644 plugins/push/config.inc.php.dist create mode 100644 plugins/push/lib/cache.php create mode 100644 plugins/push/lib/parser_notify.php create mode 100644 plugins/push/lib/service.php create mode 100644 plugins/push/push.js create mode 100644 plugins/push/push.php create mode 100644 plugins/push/server.php diff --git a/plugins/push/README b/plugins/push/README new file mode 100644 index 000000000..c56455875 --- /dev/null +++ b/plugins/push/README @@ -0,0 +1,47 @@ +Push plugin for Roundcube + +Roundcube plugin that provides support for live updates in mail user interface +by utilizing push notifications functionality of IMAP servers. + + +ARCHITECTURE +------------ + +The old solution for instant notifications in mailboxes is IMAP IDLE. This has however a big limitation +which is a need to use a separate IMAP connection for every folder. + +Recent versions of Dovecot and Cyrus IMAP provide APIs that could be used and might be better than IDLE. For example: + +- https://jmap.io/#push-mechanism +- https://www.cyrusimap.org/imap/concepts/features/event-notifications.html +- https://wiki.dovecot.org/Plugins/PushNotification + +The plugin contains two components: + +1. HTTP service that handles connections from imap notification services and passes them + to the frontend (with some format conversion logic). +2. Websocket client (frontend) that receives signals from the service and updates the UI. + + +REQUIREMENTS +------------ + +The service uses Swoole extension for PHP (https://www.swoole.co.uk/). + + +INSTALLATION +------------ + +1. Rename config.inc.php.dist to config.inc.php in the plugin folder. + For available configuration options see config.inc.php.dist file. +2. Enable the plugin in Roundcube config. +3. Start the service (`php server.php`). + Note: You can move the file to a different location, but make sure to set INSTALL_PATH constant + to Roundcube location. + + +NOTES +----- + +1. It should be executed with the same user as Roundcube, so it can have access to logs dir. +2. It does not work with session_storage=php. diff --git a/plugins/push/composer.json b/plugins/push/composer.json new file mode 100644 index 000000000..9d81a4193 --- /dev/null +++ b/plugins/push/composer.json @@ -0,0 +1,24 @@ +{ + "name": "roundcube/push", + "type": "roundcube-plugin", + "description": "Instant updates (push notification) support", + "license": "GPLv3+", + "version": "1.0", + "authors": [ + { + "name": "Aleksander Machniak", + "email": "alec@alec.pl", + "role": "Lead" + } + ], + "repositories": [ + { + "type": "composer", + "url": "http://plugins.roundcube.net" + } + ], + "require": { + "php": ">=5.3.0", + "ext-swoole": "*" + } +} diff --git a/plugins/push/config.inc.php.dist b/plugins/push/config.inc.php.dist new file mode 100644 index 000000000..ec0989c18 --- /dev/null +++ b/plugins/push/config.inc.php.dist @@ -0,0 +1,41 @@ +". +$config['push_token'] = null; + +// HTTP service configuration. For all available options see: +// https://www.swoole.co.uk/docs/modules/swoole-server/configuration +$config['push_service_config'] = array( + // 'chroot' => INSTALL_PATH, + // 'heartbeat_idle_time' => 600, + // 'heartbeat_check_interval' => 60, + // 'pid_file' => 'server.pid', + // 'ssl_cert_file' => 'ssl.cert', // PEM not DER + // 'ssl_key_file' => 'ssl.key', // PEM not DER + // 'ssl_ciphers' => '', +); + +// Shared-memory cache size (maximum number of records). +// This should be around + . +$config['push_cache_size'] = 1024; + +// Enables debug logging for http service (/push) and browser console +$config['push_debug'] = false; diff --git a/plugins/push/lib/cache.php b/plugins/push/lib/cache.php new file mode 100644 index 000000000..a0f2b1f44 --- /dev/null +++ b/plugins/push/lib/cache.php @@ -0,0 +1,85 @@ + + * + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +/** + * Indexed shared memory storage for cross-process cache + */ +class push_cache extends swoole_table +{ + protected $index; + + function __construct($size) + { + parent::__construct($size); + $this->column('data', swoole_table::TYPE_STRING, 2048); + $this->create(); + + $this->index = new swoole_table($size); + $this->index->column('user', swoole_table::TYPE_STRING, 256); + $this->index->create(); + } + + public function get($key, $field = null) + { + $result = parent::get($key); + if ($result) { + $result = json_decode($result['data'], true); + + if ($field) { + $result = $result[$field]; + } + } + + return $result; + } + + public function set($key, $value) + { + return parent::set($key, array('data' => json_encode($value))); + } + + public function add_client($client_id, $username, $metadata = array()) + { + $this->index->set($client_id, array('user' => $username)); + + $data = $this->get($username) ?: array('sockets' => array()); + $data = array_merge($data, $metadata); + $data['sockets'][] = $client_id; + $this->set($username, $data); + } + + public function del_client($client_id) + { + $username = $this->index->get($client_id, 'user'); + $data = $this->get($username) ?: array(); + + if (($key = array_search($client_id, (array) $data['sockets'])) !== false) { + unset($data['sockets'][$key]); + $data['sockets'] = array_unique($data['sockets']); + } + + $this->set($username, $data); + + return $this->index->del($client_id); + } +} diff --git a/plugins/push/lib/parser_notify.php b/plugins/push/lib/parser_notify.php new file mode 100644 index 000000000..4deb9c6f2 --- /dev/null +++ b/plugins/push/lib/parser_notify.php @@ -0,0 +1,230 @@ + + * + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +/** + * Parser of push notifications as of RFC5423 + * This format is used e.g. by Cyrus IMAP + */ +class push_parser_notify +{ + public function __construct() + { + // $this->service = push_service::get_instance(); + } + + public function parse($data) + { + if (!is_array($data) || empty($data) || !isset($data['event'])) { + return; + } + + // Get event name (remove non-standard prefixes) + $event = str_replace('vnd.cmu.', '', $data['event']); + $data['event'] = $event; + + // Parse data + $common = $this->commonProps($data); + $result = $this->{"event$event"}($data); + + if (is_array($result)) { + return array_merge($common, $result); + } + } + + public function __call($name, $arguments) + { + // Here we end up with unknown/undefined events + } + + protected function commonProps($data) + { + $uri = parse_url($data['uri']); + $user = $uri['user']; + list($folder, ) = explode(';', $uri['path']); + + $result = array( + 'service' => $data['service'], + 'uidset' => $data['uidset'], + 'exists' => $data['messages'], + 'folder_user' => urldecode($user), + 'folder_name' => urldecode(ltrim($folder, '/')), + ); + + if (isset($data['oldMailboxID'])) { + $uri = parse_url($data['oldMailboxID']); + $user = $uri['user']; + list($folder, ) = explode(';', $uri['path']); + + $result['old_folder_user'] = urldecode($user); + $result['old_folder_name'] = urldecode(ltrim($folder, '/')); + } + + if (isset($data['vnd.cmu.unseenMessages'])) { + $result['unseen'] = intval($data['vnd.cmu.unseenMessages']); + } + + return $result; + } + + protected function eventAclChange($data) + { + // Not implemented + } + + protected function eventFlagsClear($data) + { + return array( + 'flags' => explode(' ', $data['flagNames']), + ); + } + + protected function eventFlagsSet($data) + { + return array( + 'flags' => explode(' ', $data['flagNames']), + ); + } + + protected function eventLogin($data) + { + // Not implemented + } + + protected function eventLogout($data) + { + // Not implemented + } + + protected function eventMailboxCreate($data) + { + return array(); + } + + protected function eventMailboxDelete($data) + { + return array(); + } + + protected function eventMailboxRename($data) + { + return array(); + } + + protected function eventMailboxSubscribe($data) + { + return array(); + } + + protected function eventMailboxUnSubscribe($data) + { + return array(); + } + + protected function eventMessageAppend($data) + { + return array(); + } + + protected function eventMessageCopy($data) + { + return array( + 'old_uidset' => $data['vnd.cmu.oldUidset'], + ); + } + + protected function eventMessageExpire($data) + { + // Not implemented + } + + protected function eventMessageExpunge($data) + { + return array(); + } + + protected function eventMessageMove($data) + { + // this is non-standard event used in Cyrus IMAP + return $this->eventMessageCopy($data); + } + + protected function eventMessageNew($data) + { + // Add some props that might be useful if we want to display + // new message notification with message subject, etc. + // Maybe we should talk with newmail_notifier plugin + $headers = array(); + + // Note: messageHeaders is not defined in RFC5423, but used by Cyrus. + // Other properties does not include Subject header + foreach ((array) $data['messageHeaders'] as $uid => $h) { + $headers[$uid] = array( + 'subject' => $h['Subject'], + 'from' => $h['From'], + ); + } + + return array( + 'headers' => $headers, + ); + } + + protected function eventMessageRead($data) + { + // This is equivalent of FlagsSet with \Seen flag + // so let's imitate it for simplicity + return array( + 'event' => 'FlagsSet', + 'flags' => array('\\Seen'), + ); + } + + protected function eventMessageTrash($data) + { + // This is equivalent of FlagsSet with \Deleted flag + // so let's imitate it for simplicity + return array( + 'event' => 'FlagsSet', + 'flags' => array('\\Deleted'), + ); + } + + protected function eventQuotaChange($data) + { + return array( + 'quota' => $data['diskQuota'], + 'used' => $data['diskUsed'], + 'messages' => $data['maxMessages'], + ); + } + + protected function eventQuotaExceed($data) + { + // Not implemented + } + + protected function eventQuotaWithin($data) + { + return $this->eventQuotaChange($data); + } +} diff --git a/plugins/push/lib/service.php b/plugins/push/lib/service.php new file mode 100644 index 000000000..17cc0df29 --- /dev/null +++ b/plugins/push/lib/service.php @@ -0,0 +1,233 @@ + + * + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +require_once __DIR__ . '/cache.php'; + +class push_service extends rcube +{ + protected $server; + protected $clients; + protected $parser; + protected $token; + protected $debug = false; + + + /** + * This implements the 'singleton' design pattern + * + * @param int $mode Unused + * @param string $env Unused + * + * @return kolab_sync The one and only instance + */ + public static function get_instance($mode = 0, $env = '') + { + if (!self::$instance || !is_a(self::$instance, 'push_service')) { + self::$instance = new push_service(); + self::$instance->startup(); // init AFTER object was linked with self::$instance + } + + return self::$instance; + } + + /** + * Initialization of class instance + */ + public function startup() + { + require_once __DIR__ . '/parser_notify.php'; + require_once __DIR__ . '/../push.php'; + + // Use the plugin to load configuration from its config file + $plugins = rcube_plugin_api::get_instance(); + $plugin = new push($plugins); + $plugin->load_config(); + + $host = $this->config->get('push_service_host') ?: "0.0.0.0"; + $port = $this->config->get('push_service_post') ?: 9501; + $cache_size = $this->config->get('push_cache_size') ?: 1024; + + $this->debug = $this->config->get('push_debug', true); + $this->token = $this->config->get('push_token'); + $this->clients = new push_cache($cache_size); + $this->parser = new push_parser_notify; + + // Setup the server + $this->server = new swoole_websocket_server($host, $port); // TODO: SSL + $this->server->set((array) $this->config->get('push_service_config')); + $this->server->on('open', array($this, 'http_open')); + $this->server->on('message', array($this, 'http_message')); + $this->server->on('request', array($this, 'http_request')); + $this->server->on('close', array($this, 'http_close')); + + $this->log_debug("S: Service start."); + + $this->server->start(); + } + + /** + * Here we handle connections from websocket client + */ + public function http_open(swoole_websocket_server $server, swoole_http_request $request) + { + $this->log_debug("[{$request->fd}] Handshake success"); + } + + /** + * Here we close http/websocket connections + */ + public function http_close(swoole_websocket_server $server, int $fd) + { + $this->log_debug("[{$fd}] Closed"); + + // Remove connection entry from cache + $this->clients->del_client($fd); + } + + /** + * Here we handle messages from websocket clients + */ + public function http_message(swoole_websocket_server $server, swoole_websocket_frame $frame) + { + $data = json_decode($frame->data, true); + + $this->log_debug("[{$frame->fd}] Received message", $data); + + if ($data && $data['action'] == 'authenticate' + && ($user = $this->authenticate($data['session'], $data['token'])) + ) { + $this->log_debug("[{$frame->fd}] Registered session for " . $user['username']); + $this->clients->add_client($frame->fd, $user['username'], $user); + } + } + + /** + * Non-websocket request handler. + * Here we handle Push notifications from external systems (IMAP servers). + */ + public function http_request(swoole_http_request $request, swoole_http_response $response) + { + $this->log_debug("[{$request->fd}] HTTP {$request->server['request_method']} request"); + + if ($request->server['request_method'] !== 'POST') { + $response->status('404'); + $response->end(); + return; + } + + // Check security token (if configured) + if ($this->token) { + $token = $request->header['x-token']; + if (!$token && preg_match('/^Bearer (.+)/', $request->header['authorization'], $m)) { + $token = $m[1]; + } + + if ($token !== $this->token) { + $this->log_debug("[{$request->fd}] 401 Invalid token"); + $response->status('401'); + $response->end(); + return; + } + } + + // Send 200 response + // $response->header("Content-Type", "text/plain; charset=utf-8"); + $response->end(); + + // Read POST data, or JSON from POST body + $data = $request->post; + if (empty($data)) { + $data = $request->rawcontent(); + if ($data && $data[0] == '{') { + $data = json_decode($data, true); + } + } + + $this->log_debug("Received notification", $data); + + // Parse notification data (convert to internal format) + $event = $this->parser->parse($data); + + if (!empty($event) && $event['folder_user']) { + $user = $this->clients->get($event['folder_user']); + if ($user) { + $this->broadcast($user, $event); + } + + // TODO: broadcast to old_folder_user + } + } + + /** + * Similar to rcube::console(), writes to logs/push if debug option is enabled + */ + public function log_debug() + { + if ($this->debug) { + $msg = array(); + foreach (func_get_args() as $arg) { + $msg[] = !is_string($arg) ? var_export($arg, true) : $arg; + } + + rcube::write_log('push', implode(";\n", $msg)); + } + } + + /** + * Broadcast the message to all specified websocket clients + */ + protected function broadcast($client, $event) + { + foreach ((array) $client['sockets'] as $fd) { + if ($json = json_encode($event)) { + $this->log_debug("[$fd] Sending message to " . $client['username'], $json); + $this->server->push($fd, $json); + } + } + } + + /** + * Websocket authentication. + * + * Checks if specified session ID and request token match + * and returns user metadata from that session + */ + protected function authenticate($session_id, $token) + { + if (!$token || !$session_id) { + return; + } + + $session = rcube_session::factory($this->config); + + if ($data = $session->read($session_id)) { + $session = $session->unserialize($data); + if ($session && $token === $session['request_token']) { + return array( + 'username' => $session['username'], + // 'password' => $session['password'], + ); + } + } + } +} diff --git a/plugins/push/push.js b/plugins/push/push.js new file mode 100644 index 000000000..dfdc86465 --- /dev/null +++ b/plugins/push/push.js @@ -0,0 +1,347 @@ +/* + * Push plugin + * + * Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js + * + * @author Aleksander Machniak + * + * @licstart The following is the entire license notice for the + * JavaScript code in this file. + * + * Copyright (C) 2015-2018 Mattermost, Inc. + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +var MAX_WEBSOCKET_FAILS = 7; +var MIN_WEBSOCKET_RETRY_TIME = 3 * 1000; // 3 sec +var MAX_WEBSOCKET_RETRY_TIME = 5 * 60 * 1000; // 5 mins +var WEBSOCKET_PING_INTERVAL = 30 * 1000; // 30 sec + +function PushSocketClient() +{ + var Socket; + + this.conn = null; + this.connectionUrl = null; + this.sequence = 1; + this.connectFailCount = 0; + this.eventCallback = null; + this.firstConnectCallback = null; + this.reconnectCallback = null; + this.errorCallback = null; + this.closeCallback = null; + this.connectingCallback = null; + this.dispatch = null; + this.getState = null; + this.stop = false; + this.platform = ''; + this.ping_timeout = null; + this.debug = false; + + this.initialize = function(dispatch, getState, opts) + { + var forceConnection = opts.forceConnection || true, + webSocketConnector = opts.webSocketConnector || WebSocket, + connectionUrl = opts.connectionUrl, + platform = opts.platform, + self = this; + + if (platform) { + this.platform = platform; + } + + if (forceConnection) { + this.stop = false; + } + + this.debug = opts.debug; + + return new Promise(function(resolve, reject) { + if (self.conn) { + resolve(); + return; + } + + if (connectionUrl == null) { + console.error('websocket must have connection url'); + reject('websocket must have connection url'); + return; + } + + if (!dispatch) { + console.error('websocket must have a dispatch'); + reject('websocket must have a dispatch'); + return; + } + + if (self.connectFailCount === 0) { + self.log('websocket connecting to ' + connectionUrl); + } + + Socket = webSocketConnector; + if (self.connectingCallback) { + self.connectingCallback(dispatch, getState); + } + + var regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/; + var captured = (regex).exec(connectionUrl); + var origin; + + if (captured) { + origin = captured[0]; + + if (platform === 'android') { + // this is done cause for android having the port 80 or 443 will fail the connection + // the websocket will append them + var split = origin.split(':'); + var port = split[2]; + if (port === '80' || port === '443') { + origin = split[0] + ':' + split[1]; + } + } + } else { + // If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway + console.warn('websocket failed to parse origin from ' + connectionUrl); + } + + self.conn = new Socket(connectionUrl, [], {headers: {origin}}); + self.connectionUrl = connectionUrl; + self.dispatch = dispatch; + self.getState = getState; + + self.conn.onopen = function() { + self.log('websocket connected'); + + if (self.connectFailCount > 0) { + self.log('websocket re-established connection'); + if (self.reconnectCallback) { + self.reconnectCallback(self.dispatch, self.getState); + } + } else if (self.firstConnectCallback) { + self.firstConnectCallback(self.dispatch, self.getState); + } + + self.connectFailCount = 0; + resolve(); + }; + + self.conn.onclose = function() { + self.conn = null; + self.sequence = 1; + + if (self.connectFailCount === 0) { + self.log('websocket closed'); + } + + self.connectFailCount++; + + clearTimeout(this.ping_timeout); + + if (self.closeCallback) { + self.closeCallback(self.connectFailCount, self.dispatch, self.getState); + } + + var retryTime = MIN_WEBSOCKET_RETRY_TIME; + + // If we've failed a bunch of connections then start backing off + if (self.connectFailCount > MAX_WEBSOCKET_FAILS) { + retryTime = MIN_WEBSOCKET_RETRY_TIME * self.connectFailCount; + if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { + retryTime = MAX_WEBSOCKET_RETRY_TIME; + } + } + + setTimeout(function() { + if (self.stop) { + return; + } + self.initialize(dispatch, getState, Object.assign({}, opts, {forceConnection: true})); + }, + retryTime + ); + }; + + self.conn.onerror = function(evt) { + if (self.connectFailCount <= 1) { + self.log('websocket error'); + console.error(evt); + } + + clearTimeout(this.ping_timeout); + + if (self.errorCallback) { + self.errorCallback(evt, self.dispatch, self.getState); + } + }; + + self.conn.onmessage = function(evt) { + var msg = JSON.parse(evt.data); + + self.log(msg); + + if (msg.error) { + console.warn(msg); + } + else if (self.eventCallback) { + self.eventCallback(msg, self.dispatch, self.getState); + } + self.ping(); + }; + }); + } + + this.setConnectingCallback = function(callback) + { + this.connectingCallback = callback; + } + + this.setEventCallback = function(callback) + { + this.eventCallback = callback; + } + + this.setFirstConnectCallback = function(callback) + { + this.firstConnectCallback = callback; + } + + this.setReconnectCallback = function(callback) + { + this.reconnectCallback = callback; + } + + this.setErrorCallback = function(callback) + { + this.errorCallback = callback; + } + + this.setCloseCallback = function(callback) + { + this.closeCallback = callback; + } + + this.log = function(data) + { + if (this.debug) { + console.log(data); + } + } + + this.ping = function() + { + var self = this; + clearTimeout(this.ping_timeout); + this.ping_timeout = setTimeout(function() { self.sendMessage('ping', {}); }, WEBSOCKET_PING_INTERVAL); + } + + this.close = function(stop) + { + this.stop = stop; + this.connectFailCount = 0; + this.sequence = 1; + + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.onclose = function(){}; + this.conn.close(); + this.conn = null; + this.log('websocket closed'); + } + } + + this.sendMessage = function(action, data) + { + var msg = $.extend({}, data, {action: action, seq: this.sequence++}); + + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.send(JSON.stringify(msg)); + this.ping(); + } else if (!this.conn || this.conn.readyState === Socket.CLOSED) { + this.conn = null; + this.initialize(this.dispatch, this.getState, {forceConnection: true, platform: this.platform}); + } + } +} + +/** + * Initializes and starts websocket connection with Mattermost + */ +function push_websocket_init() +{ + var api = new PushSocketClient(), + onconnect = function() { + api.sendMessage('authenticate', {token: rcmail.env.request_token, session: rcmail.env.sessid}); + }; + + api.setEventCallback(function(e) { push_event_handler(e); }); + api.setFirstConnectCallback(onconnect); + api.setReconnectCallback(onconnect); + + api.initialize({}, {}, {connectionUrl: rcmail.env.push_url, debug: rcmail.env.push_debug}); +} + +/** + * Handles websocket events + */ +function push_event_handler(event) +{ + if (!event || !event.event) { + return; + } + + var event_name = event.event, + folder = event.folder_name; + + // All events can provide unseen messages count, + // mark the specified folder accordingly + if ('unseen' in event && folder) { + rcmail.set_unread_count(folder, event.unseen, folder == 'INBOX', + event_name == 'MessageNew' ? 'recent' : null); + } + + if ('exists' in event && folder === rcmail.env.trash_mailbox) { + rcmail.set_trash_count(event.exists); + } + + if (folder === rcmail.env.mailbox) { + // New messages or deleted messages in the current folder + if (event_name === 'MessageNew' || event_name === 'MessageAppend' + || event_name === 'MessageExpire' || event_name === 'MessageExpunge' + || event_name === 'MessageCopy' || event_name === 'MessageMove' + ) { + // TODO: Update messages list + } + + if (event_name === 'FlagsSet' || event_name === 'FlagsClear') { + // TODO: Update messages list + } + } + + if (event_name === 'MailboxCreate' || event_name === 'MailboxDelete' + || event_name === 'MailboxRename' || event_name === 'MailboxSubscribe' + || event_name === 'MailboxUnSubscribe' + ) { + // TODO: display a notification and ask the user if he want's to reload the page + // to refresh folders list, or (more complicated) update folders list automatically + } + + if (event_name === 'MessageNew') { + // TODO: display newmail_notifier-like notification about a new message + } +} + +window.WebSocket && window.rcmail && rcmail.addEventListener('init', function() { + push_websocket_init(); +}); diff --git a/plugins/push/push.php b/plugins/push/push.php new file mode 100644 index 000000000..e2e289682 --- /dev/null +++ b/plugins/push/push.php @@ -0,0 +1,62 @@ + + * + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +class push extends rcube_plugin +{ + public $task = 'mail'; + public $rc; + + + /** + * Plugin initialization + */ + public function init() + { + $this->rc = rcube::get_instance(); + + $this->add_hook('ready', array($this, 'ready')); + } + + /** + * Startup hook handler + */ + public function ready($args) + { + if ($this->rc->output->type == 'html' && !$this->rc->action) { + $this->load_config(); + + $debug = (bool) $this->rc->config->get('push_debug'); + $port = $this->rc->config->get('push_service_port', 9501); + $url = $this->rc->config->get('push_url') ?: 'wss://%n:%p'; + $url = rcube_utils::parse_host($url); + $url = str_replace('%p', $port, $url); + + $this->rc->output->set_env('push_url', $url); + $this->rc->output->set_env('push_debug', $debug); + $this->rc->output->set_env('sessid', session_id()); + + $this->include_script('push.js'); + } + + return $args; + } +} diff --git a/plugins/push/server.php b/plugins/push/server.php new file mode 100644 index 000000000..4283d5ad3 --- /dev/null +++ b/plugins/push/server.php @@ -0,0 +1,34 @@ + + * + * Copyright (C) 2010-2019 The Roundcube Dev Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +if (php_sapi_name() != 'cli') { + die('Not on the "shell" (php-cli).'); +} + +define('INSTALL_PATH', __DIR__ . '/../../'); + +require_once INSTALL_PATH . 'program/include/iniset.php'; +require_once INSTALL_PATH . 'plugins/push/lib/service.php'; + +// Start the service +$PUSH = push_service::get_instance();