diff --git a/api/js/jsapi/egw_links.js b/api/js/jsapi/egw_links.js index b9d6863231..d69d99b6d9 100644 --- a/api/js/jsapi/egw_links.js +++ b/api/js/jsapi/egw_links.js @@ -50,6 +50,39 @@ egw.extend('links', egw.MODULE_GLOBAL, function() */ let title_uid = null; + /** + * Encode query parameters + * + * @param object|array|string _values + * @param string? _prefix + * @param array? _query + * @return array + */ + function urlencode(_values, _prefix, _query) + { + if (typeof _query === 'undefined') _query = []; + if (Array.isArray(_values)) + { + if (!_prefix) throw "array of value needs a prefix"; + for(const value of _values) + { + _query.push(_prefix+'[]='+encodeURIComponent(value)); + } + } + else if (_values && typeof _values === 'object') + { + for(const name in _values) + { + urlencode(_values[name], _prefix ? _prefix+'['+name+']' : name, _query); + } + } + else + { + _query.push(_prefix+'='+encodeURIComponent(_values || '')); + } + return _query; + } + return { /** * Check if $app is in the registry and has an entry for $name @@ -339,25 +372,7 @@ egw.extend('links', egw.MODULE_GLOBAL, function() } // if there are vars, we add them urlencoded to the url - let query = []; - - for(let name in vars) - { - let val = vars[name] || ''; // fix error for eg. null, which is an object! - if (typeof val == 'object') - { - for(let i=0; i < val.length; ++i) - { - query.push(name+'[]='+encodeURIComponent(val[i])); - } - } - else - { - query.push(name+'='+encodeURIComponent(val)); - } - } - - return query.length ? _url+'?'+query.join('&') : _url; + return Object.keys(vars).length ? _url+'?'+urlencode(vars).join('&') : _url; }, /** diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 88b395a41e..f8063a92bd 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -277,6 +277,20 @@ class CalDAV extends HTTP_WebDAV_Server $this->dav_powered_by = str_replace('EGroupware','EGroupware '.$GLOBALS['egw_info']['server']['versions']['phpgwapi'], $this->dav_powered_by); + // detected available additional APIs from applications + $this->root += Cache::getInstance(__CLASS__, 'user-'.$GLOBALS['egw_info']['user']['account_id'], static function() + { + $apis = []; + foreach($GLOBALS['egw_info']['user']['apps'] as $app => $data) + { + if (class_exists('EGroupware\\'.ucfirst($app).'\\ApiHandler')) + { + $apis[$app] = []; + } + } + return $apis; + }, [], 86400); + parent::__construct(); // hack to allow to use query parameters in WebDAV, which HTTP_WebDAV_Server interprets as part of the path list($this->_SERVER['REQUEST_URI']) = explode('?',$this->_SERVER['REQUEST_URI']); diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 90f0185bee..767b1d5165 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -414,8 +414,12 @@ abstract class Handler if (!array_key_exists($app,$handler_cache)) { - $class = $app.'_groupdav'; - if (!class_exists($class) && !class_exists($class = __NAMESPACE__.'\\'.ucfirst($app))) return null; + if (!class_exists($class='EGroupware\\'.ucfirst($app).'\\ApiHandler') && + !class_exists($class=$app.'_groupdav') && + !class_exists($class=__NAMESPACE__.'\\'.ucfirst($app))) + { + return null; + } $handler_cache[$app] = new $class($app, $groupdav); } diff --git a/api/src/Session.php b/api/src/Session.php index a47e07d833..5eb299d1e6 100644 --- a/api/src/Session.php +++ b/api/src/Session.php @@ -1570,26 +1570,35 @@ class Session // if there are vars, we add them urlencoded to the url if (count($vars)) { - $query = array(); - foreach($vars as $key => $value) - { - if (is_array($value)) - { - foreach($value as $val) - { - $query[] = $key.'[]='.urlencode($val); - } - } - else - { - $query[] = $key.'='.urlencode($value ?? ''); - } - } - $ret_url .= '?' . implode('&',$query); + $ret_url .= '?' . implode('&', self::urlencode($vars)); } return $ret_url; } + /** + * Recursively encode GET parameters + * + * @param array|string $values + * @param string $prefix + * @param array& $query + * @return array + */ + protected static function urlencode($values, string $prefix='', array &$query=[]) + { + if (is_array($values)) + { + foreach($values as $name => $value) + { + self::urlencode($value, $prefix ? $prefix.'['.(is_int($name) ? '' : $name).']' : $name, $query); + } + } + else + { + $query[] = $prefix.'='.urlencode($values); + } + return $query; + } + /** * Regexp to validate IPv4 and IPv6 */ diff --git a/doc/REST-CalDAV-CardDAV/Mail.md b/doc/REST-CalDAV-CardDAV/Mail.md new file mode 100644 index 0000000000..f4fb4cf886 --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/Mail.md @@ -0,0 +1,124 @@ +# EGroupware REST API for Mail + +> Currently only sending mail or launching interactive compose windows + +Implemented requests (relative to https://example.org/egroupware/groupdav.php) + +- ```GET /mail``` get different mail accounts available to user +
+ Example: Querying available identities / signatures + +```bash +curl -i https://example.org/egroupware/mail --user -H 'Accept: application/json' +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 + +{ + "responses": { +"/ralf/mail/1": "Ralf Becker boulder.egroupware.org ", +"/ralf/mail/52": "Ralf Becker ", +"/ralf/mail/85": "Ralf Becker " + } +} +``` +
+ +- ```POST /mail[/]``` send mail for default or given account +
+ Example: Sending mail + +The content of the POST request is a JSON encoded object with following attributes +- ```to```: array of strings with (RFC882) email addresses like ```["info@egroupware.org", "Ralf Becker ", "/home//", ...]``` +- ```attachmentType```: one of the following strings (optional, default "attach") + - "attach" send as attachment + - "link" send as sharing link + - "share_ro" send a readonly share using the current file content (VFS only) + - "share_rw" send as writable share (VFS and EPL only) +- ```shareExpiration```: "yyyy-mm-dd", default not accessed in 100 days (EPL only) +- ```sharePassword```: string with password required to access share, default none (EPL only) +- ```folder```: folder to store send mail, default Sent folder + +```bash +curl -i https://example.org/egroupware/mail --user \ + -X POST -H 'Content-Type: application/json' \ + --content `{"to":["info@egroupware.org"],"subject":"Testmail","body":"This is a test :)\n\nRegards"}` +HTTP/1.1 204 No Content +``` +If you are not authenticated you will get: +``` +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Basic realm="EGroupware CalDAV/CardDAV/GroupDAV server" +X-WebDAV-Status: 401 Unauthorized +``` +If there is an error sending the mail you will get: +``` +HTTP/1.1 500 Internal Server Error +Content-Type: application/json +Content-Length: ... + +{"error": 123,"message":"SMTP Server not reachable"} +``` +
+ +- ```POST /mail[/]/compose``` launch compose window +
+ Example: Opening a compose window + +Parameters are identical to send mail request above, thought there are additional responses: +- compose window successful opened +``` +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "status": 200, + "message": "Request to open compose window sent", + "extra": { + "preset": { + "to": [ + "Birgit Becker This is a test :)\n\nRegards", + "mimeType": "html", + "identity": "52" + } + } +} +``` +- user is not online, therefore compose window can NOT be opened +``` +404 Not found +Content-Type: application/json + +{ + "error": 404, + "message": "User 'ralf' (#5) is NOT online" +} +``` +
+ +- ```POST /mail/attachments/``` upload mail attachments +
+ Example: Uploading an attachment to be used for sending or composing mail + +The content of the POST request is the attachment, a Location header in the response gives you a URL +to use in further requests, instead of the attachment. + +``` +curl -i https://example.org/egroupware/mail/attachment/ --user \ + --data-binary @ -H 'Content-Type: ' +HTTP/1.1 204 No Content +Location: https://example.org/egroupware/mail/attachment/ +``` +
\ No newline at end of file diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 26a077d051..9fe1961e62 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -31,6 +31,7 @@ One can use the following URLs relative (!) to https://example.org/egroupware/gr - ```/infolog/``` infologs of current user - ```/(resources|locations)//calendar``` calendar of a resource/location, if user has rights to view - ```//(resource|location)-``` shared calendar from a resource/location +- ```/mail/``` only REST API, currently only send EMail or launch interactive compose windows Shared addressbooks or calendars are only shown in the users home-set, if he subscribed to it via his CalDAV preferences! @@ -544,4 +545,4 @@ use ```:``` like in JsCalendar - [ ] InfoLog - [ ] Calendar - [ ] relatedTo / links -- [ ] storing not native supported attributes eg. localization +- [ ] storing not native supported attributes eg. localization \ No newline at end of file diff --git a/mail/inc/class.mail_compose.inc.php b/mail/inc/class.mail_compose.inc.php index 7f82391be8..456fda00f0 100644 --- a/mail/inc/class.mail_compose.inc.php +++ b/mail/inc/class.mail_compose.inc.php @@ -68,11 +68,11 @@ class mail_compose var $composeID; var $sessionData; - function __construct() + function __construct(int $_acc_id=null) { $this->displayCharset = Api\Translation::charset(); - $profileID = (int)$GLOBALS['egw_info']['user']['preferences']['mail']['ActiveProfileID']; + $profileID = $_acc_id ?: (int)$GLOBALS['egw_info']['user']['preferences']['mail']['ActiveProfileID']; $this->mail_bo = Mail::getInstance(true,$profileID); $GLOBALS['egw_info']['user']['preferences']['mail']['ActiveProfileID'] = $this->mail_bo->profileID; @@ -397,14 +397,7 @@ class mail_compose // and we want to avoid that $isFirstLoad = !($actionToProcess=='composeasnew');//true; $this->composeID = $_content['composeID'] = $this->generateComposeID(); - if (!is_array($_content)) - { - $_content = $this->setDefaults(); - } - else - { - $_content = $this->setDefaults($_content); - } + $_content = $this->setDefaults($_content+['mailidentity' => $_REQUEST['preset']['identity'] ?? null]); } // VFS Selector was used if (!empty($_content['selectFromVFSForCompose'])) diff --git a/mail/src/ApiHandler.php b/mail/src/ApiHandler.php new file mode 100644 index 0000000000..2c8d648b25 --- /dev/null +++ b/mail/src/ApiHandler.php @@ -0,0 +1,297 @@ + + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Mail; + +use EGroupware\Api; + +/** + * REST API for mail + */ +class ApiHandler extends Api\CalDAV\Handler +{ + /** + * Constructor + * + * @param string $app 'calendar', 'addressbook' or 'infolog' + * @param Api\CalDAV $caldav calling class + */ + function __construct($app, Api\CalDAV $caldav) + { + parent::__construct($app, $caldav); + } + + /** + * Options for json_encode of responses + */ + const JSON_RESPONSE_OPTIONS = JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES; + + /** + * Handle post request for mail (send or compose mail and upload attachments) + * + * @param array &$options + * @param int $id + * @param int $user =null account_id of owner, default null + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function post(&$options,$id,$user=null) + { + if ($this->debug) error_log(__METHOD__."($id, $user)".print_r($options,true)); + $path = $options['path']; + if (empty($user)) + { + $user = $GLOBALS['egw_info']['user']['account_id']; + } + else + { + $prefix = '/'.Api\Accounts::id2name($user); + if (str_starts_with($path, $prefix)) $path = substr($path, strlen($prefix)); + } + + try { + if (str_starts_with($path, '/mail/attachments/')) + { + $attachment_path = tempnam($GLOBALS['egw_info']['server']['temp_dir'], 'attach--'. + (str_replace('/', '-', substr($options['path'], 18)) ?: 'no-name').'--'); + if (file_put_contents($attachment_path, $options['content'])) + { + header('Location: '.($location = '/mail/attachments/'.substr(basename($attachment_path), 8))); + echo json_encode([ + 'status' => 200, + 'message' => 'Attachment stored', + 'location' => $location, + ], self::JSON_RESPONSE_OPTIONS); + return '200 Ok'; + } + throw new \Exception('Error storing attachment'); + } + elseif (preg_match('#^/mail/((\d+):(\d+)/)?(compose)?#', $path, $matches)) + { + $acc_id = $matches[2] ?? null; + $ident_id = $matches[3] ?? null; + $do_compose = (bool)($matches[4] ?? false); + if (!($data = json_decode($options['content'], true))) + { + throw new \Exception('Error decoding JSON: '.json_last_error_msg(), 422); + } + // ToDo: check required attributes + + // for compose we need to construct a URL and push it to the client (or give an error if the client is not online) + if ($do_compose) + { + if (!Api\Json\Push::isOnline($user)) + { + $account_lid = Api\Accounts::id2name($user); + throw new \Exception("User '$account_lid' (#$user) is NOT online", 404); + } + $extra = [ + //'menuaction' => 'mail.mail_compose.compose', + 'preset' => array_filter(array_intersect_key($data, array_flip(['to', 'cc', 'bcc', 'subject']))+[ + 'body' => $data['bodyHtml'] ?? null ?: $data['body'] ?? '', + 'mimeType' => !empty($data['bodyHtml']) ? 'html' : 'plain', + 'identity' => $ident_id, + ]+self::prepareAttachments($data['attachments'] ?? [], $data['attachmentType'] ?? 'attach')), + ]; + $push = new Api\Json\Push($user); + //$push->call('egw.open_link', $link, '_blank', '640x1024'); + $push->call('egw.open', '', 'mail', 'add', $extra, '_blank', 'mail'); + header('Content-Type: application/json'); + echo json_encode([ + 'status' => 200, + 'message' => 'Request to open compose window sent', + 'extra' => $extra, + ], self::JSON_RESPONSE_OPTIONS); + return true; + } + + $compose = new mail_compose($acc_id); + $compose->compose([ + 'mailaccount' => $acc_id.':'.$ident_id, + 'mail_plaintext' => $data['body'] ?? null, + 'mail_htmltext' => $data['bodyHtml'] ?? null, + 'mimeType' => !empty($data['bodyHtml']) ? 'html' : 'plain', + 'file' => array_map(__CLASS__.'::uploadsForAttachments', $data['attachments'] ?? []), + ]+array_diff_key($data+array_flip(['attachments', 'body', 'bodyHtml']))); + } + + header('Content-Type: application/json'); + echo $options['content']; + return true; + } + catch (\Throwable $e) { + _egw_log_exception($e); + header('Content-Type: application/json'); + echo json_encode([ + 'error' => $code = $e->getCode() ?: 500, + 'message' => $e->getMessage(), + ]+(empty($GLOBALS['egw_info']['server']['exception_show_trace']) ? [] : [ + 'trace' => array_map(static function($trace) + { + $trace['file'] = str_replace(EGW_SERVER_ROOT.'/', '', $trace['file']); + return $trace; + }, $e->getTrace()) + ]), self::JSON_RESPONSE_OPTIONS); + return (400 <= $code && $code < 600 ? $code : 500).' '.$e->getMessage(); + } + } + + /** + * Convert an attachment name into an upload array for mail_compose::compose + * + * @param string[]? $attachments either "/mail/attachments/" / file in temp_dir or VFS path + * @param string? $attachmentType "attach" (default), "link", "share_ro", "share_rw" + * @return array with values for keys "file", "name" and "filemode" + * @throws Exception if file not found or unreadable + */ + protected static function prepareAttachments(array $attachments, string $attachmentType=null) + { + $ret = []; + foreach($attachments as $attachment) + { + if (preg_match('#^/mail/attachments/(([^/]+)--[^/.-]{6,})$#', $attachment, $matches)) + { + if (!file_exists($path=$GLOBALS['egw_info']['server']['temp_dir'].'/attach--'.$matches[1])) + { + throw new \Exception("Attachment $attachment NOT found", 400); + } + $ret['file'][] = $path; + $ret['name'][] = $matches[2]; + /*return [ + 'name' => $matches[2], + 'type' => Api\Vfs::mime_content_type($path), + 'file' => $path, + 'size' => filesize($path), + ];*/ + } + else + { + if (!Api\Vfs::is_readable($attachment)) + { + throw new \Exception("Attachment $attachment NOT found", 400); + } + $ret['file'][] = Api\Vfs::PREFIX.$attachment; + $ret['name'][] = Api\Vfs::basename($attachment); + /*return [ + 'name' => Api\Vfs::basename($attachment), + 'type' => Api\Vfs::mime_content_type($attachment), + 'file' => Api\Vfs::PREFIX.$attachment, + 'size' => filesize(Api\Vfs::PREFIX.$attachment), + ];*/ + } + } + if ($ret) + { + $ret['filemode'] = $attachmentType ?? 'attach'; + if (!in_array($ret['filemode'], $valid=['attach', 'link', 'share_ro', 'share_rw'])) + { + throw new \Exception("Invalid value '$ret[filemode]' for attachmentType, must be one of: '".implode("', '", $valid)."'", 422); + } + } + return $ret; + } + + /** + * Handle propfind request for an application folder + * + * @param string $path + * @param array &$options + * @param array &$files + * @param int $user account_id + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function propfind($path,&$options,&$files,$user) + { + if ($path === '/mail/' || $user && $path === '/'.Api\Accounts::id2name($user).'/mail/') + { + foreach(Api\Mail\Account::search($user ?? true,false) as $acc_id => $account) + { + // do NOT add SMTP only accounts as identities + if (!$account->is_imap(false)) continue; + + foreach($account->identities($acc_id) as $ident_id => $identity) + { + $files['files'][] = [ + 'path' => $path.$ident_id, + 'props' => ['name' => ['val' => $identity]], + ]; + } + } + return true; + } + return '501 Not Implemented'; + } + + /** + * Handle get request for an applications entry + * + * @param array &$options + * @param int $id + * @param int $user =null account_id + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function get(&$options,$id,$user=null) + { + header('Content-Type: application/json'); + echo json_encode($all=iterator_to_array(Api\Mail\Account::identities([], true, 'name', + $user ?: $GLOBALS['egw_info']['user']['account_id'])), JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); + return true; + return '501 Not Implemented'; + } + + /** + * Handle get request for an applications entry + * + * @param array &$options + * @param int $id + * @param int $user =null account_id of owner, default null + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function put(&$options,$id,$user=null) + { + return '501 Not Implemented'; + } + + /** + * Handle get request for an applications entry + * + * @param array &$options + * @param int $id + * @param int $user account_id of collection owner + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function delete(&$options,$id,$user) + { + return '501 Not Implemented'; + } + + /** + * Read an entry + * + * @param string|int $id + * @param string $path =null implementation can use it, used in call from _common_get_put_delete + * @return array|boolean array with entry, false if no read rights, null if $id does not exist + */ + function read($id /*,$path=null*/) + { + return '501 Not Implemented'; + } + + /** + * Check if user has the necessary rights on an entry + * + * @param int $acl Api\Acl::READ, Api\Acl::EDIT or Api\Acl::DELETE + * @param array|int $entry entry-array or id + * @return boolean null if entry does not exist, false if no access, true if access permitted + */ + function check_access($acl,$entry) + { + return true; + } +} \ No newline at end of file