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