Compare commits

...

3 Commits

Author SHA1 Message Date
Aleksander Machniak 05db0f5d87 Push: Describe deployment on Cyrus IMAP 5 years ago
Aleksander Machniak 77b6e2606a Push: Support cyrus's URI format 5 years ago
Aleksander Machniak ad5f2f0e6f Push plugin (#6544) 5 years ago

@ -0,0 +1,63 @@
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.
INSTALLATION - CYRUS IMAP
-------------------------
0. Make sure the server has PHP with Curl extension installed.
1. Make sure notify service is enabled in cyrus.conf.
2. Update imapd.conf with:
```
notify_external: /usr/share/roundcubemail/plugins/push/helpers/notify-cyrus.sh
event_notifier: external
event_groups: message mailbox flags subscription
event_extra_params: diskUsed messages vnd.cmu.unseenMessages flagNames
```
3. Configure $URL and $TOKEN in notify-cyrus.sh script.
4. Restart cyrus-imapd.
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.

@ -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": "*"
}
}

@ -0,0 +1,41 @@
<?php
// Websocket connection URL
// This is where web browsers connect to the service
// Supported replacement variables:
// %h - user's IMAP hostname
// %n - hostname ($_SERVER['SERVER_NAME'])
// %t - hostname without the first part
// %d - domain (http hostname $_SERVER['HTTP_HOST'] without the first part)
// %z - IMAP domain (IMAP hostname without the first part)
// %p - Port specified in $config['push_service_port'] (or 9501 if unset)
$config['push_url'] = 'wss://%n:%p';
// To which IP/Port the service should bind to
// Note: The service need to be accessible by web browsers and notification services
$config['push_service_host'] = '0.0.0.0';
$config['push_service_port'] = 9501;
// Additional security token that external services will have to set
// when connecting to http service of the plugin. The token can be specified
// either in X-Token header, or Authorization header with value "Bearer <token>".
$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 <users_count> + <active_sessions_count>.
$config['push_cache_size'] = 1024;
// Enables debug logging for http service (<log_dir>/push) and browser console
$config['push_debug'] = false;

@ -0,0 +1,52 @@
#!/usr/bin/env php
<?php
/**
* Push plugin helper for Cyrus IMAP
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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/.
*/
///////////////////// CONFIGURATION /////////////////////
$URL = "http://127.0.0.1:9501";
$TOKEN = "xyz";
/////////////////////////////////////////////////////////
if (php_sapi_name() != 'cli') {
die('Not on the "shell" (php-cli).');
}
$input = file_get_contents('php://stdin');
// Debug
file_put_contents('/tmp/notify.log', "$input\n", FILE_APPEND);
$curl = curl_init($URL);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $input);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Content-Length: ' . strlen($input),
'X-Token: ' . $TOKEN
));
curl_exec($curl);
curl_close($curl);

@ -0,0 +1,85 @@
<?php
/**
* Push plugin
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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);
}
}

@ -0,0 +1,283 @@
<?php
/**
* Push aka Instant Updates
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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 = $this->parseURI($data['uri']);
$result = array(
'event' => $data['event'],
'service' => $data['service'],
'uidset' => $data['uidset'],
'exists' => isset($data['messages']) ? intval($data['messages']) : null,
'folder_user' => $uri['user'] ?: $data['user'],
'folder_name' => $uri['folder'],
);
if (isset($data['oldMailboxID'])) {
$uri = $this->parseURI($data['oldMailboxID']);
$result['old_folder_user'] = $uri['user'] ?: $data['user'];
$result['old_folder_name'] = $uri['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);
}
/**
* Parse imap folder URI to extract user and folder name
*/
protected function parseURI($uri)
{
$_uri = parse_url($uri);
list($path, $args) = explode(';', $_uri['path'], 2);
$path = ltrim($path, '/');
$params = array();
// Standard: "imap://test%40example.org@imap.example.org/INBOX";
if (!empty($_uri['user'])) {
$user = urldecode($_uri['user']);
$path = strlen($path) ? urldecode($path) : 'INBOX';
}
// (Old?) Cyrus IMAP: imap://imap.example.org/user/test.test/Sent%40example.org
else if (strpos($path, 'user/') === 0) {
$path = array_map('urldecode', explode('/', $path));
if (count($path) > 1) {
$last = count($path) - 1;
$user = $path[1];
if ($last > 1 && ($pos = strrpos($path[$last], '@'))) {
$user .= substr($path[$last], $pos);
$path[$last] = substr($path[$last], 0, $pos);
}
$path = implode('/', array_slice($path, 2));
if (!strlen($path)) {
$path = 'INBOX';
}
}
}
// E.g. for MailboxSubscribe the uri can be also just imap://imap.example.org/Folder
if (empty($user) && empty($path)) {
// invalid uri?
}
if (!empty($args)) {
foreach (explode(';', $args) as $arg) {
list($key, $val) = explode('=', $arg);
$params[urldecode($key)] = urldecode($val);
}
}
return array(
'user' => $user,
'folder' => $path,
'params' => $params,
);
}
}

@ -0,0 +1,246 @@
<?php
/**
* Push aka Instant Updates
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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', false);
$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();
$this->notification($request);
}
/**
* 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));
}
}
/**
* Handles incoming notification request
*/
protected function notification($request)
{
// 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
// TODO: shared folders
}
}
/**
* Broadcast the message to all specified websocket clients
*/
protected function broadcast($client, $event)
{
// remove null items
$event = array_filter($event, function($v) { return !is_null($v); });
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'],
);
}
}
}
}

@ -0,0 +1,347 @@
/*
* Push plugin
*
* Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js
*
* @author Aleksander Machniak <alec@alec.pl>
*
* @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();
});

@ -0,0 +1,62 @@
<?php
/**
* Push aka Instant Updates
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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;
}
}

@ -0,0 +1,34 @@
<?php
/**
* Push aka Instant Updates
*
* @author Aleksander Machniak <alec@alec.pl>
*
* 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();
Loading…
Cancel
Save