Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- [Enable SSL Verification](#enable-ssl-verification)
- [Per-User Encryption of Config-Values](#per-user-encryption-of-config-values)
- [CardDAV Integration](#carddav-integration)
- [RoundCube Bridge](#roundcube-bridge)
- [Personal Settings](#personal-settings)
- [Email Login Name](#email-login-name)
- [Email Password](#email-password)
Expand Down Expand Up @@ -348,6 +349,39 @@ RoundCube CardDAV plugin -- which may work or not. In order to have
auto-configuration working it is vital to **not** include "username" and
"password" into the "fixed" array.

##### RoundCube Bridge

This app provides a communication bridge for compatible RoundCube plugins, allowing seamless integration with Nextcloud services (files, calendar) from within the email interface.

**How it works:**

When enabled in admin settings, the app establishes a communication bridge between RoundCube (running in an iframe) and Nextcloud using the postMessage API. This allows compatible plugins to use Nextcloud's native file picker, calendar, and other services. All operations are executed by Nextcloud itself - RoundCube only sends requests via postMessage.

**Example Implementation: NextBridge Plugin**

The [NextBridge Roundcube plugin](https://github.com/Gecka-Apps/NextBridge) is the first plugin to use this bridge. It allows users to:
- Attach files from Nextcloud storage to emails
- Save email attachments directly to Nextcloud files
- Insert public share links into email body
- Add calendar invitations (.ics) to Nextcloud Calendar

**Setup:**

1. Enable "RoundCube bridge" in Nextcloud admin settings (disabled by default)

2. Install the NextBridge plugin in your Roundcube installation:
```bash
cd /path/to/roundcube/plugins/
git clone https://github.com/Gecka-Apps/NextBridge.git nextbridge
```

3. Enable the plugin in Roundcube's `config/config.inc.php`:
```php
$config['plugins'] = array('nextbridge', /* other plugins */);
```

4. **That's it!** When users access Roundcube through Nextcloud, the bridge is automatically available.

### Personal Settings

Please have also a look at the
Expand Down
6 changes: 6 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,11 @@
'verb' => 'GET',
'postfix' => '.all',
],
// Calendar API
[
'name' => 'calendar#add_event',
'url' => '/api/calendar/event',
'verb' => 'POST',
],
]
];
328 changes: 328 additions & 0 deletions lib/Controller/CalendarController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
<?php
/**
* Calendar Controller for RoundCube App.
*
* Handles calendar event operations with proper handling of
* Nextcloud's soft-deleted events (orphaned UIDs).
*
* @author Laurent Dinclaux <laurent@gecka.nc>
* @copyright 2026 Gecka
* @license AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\RoundCube\Controller;

use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;

/**
* Controller for calendar event operations.
*/
class CalendarController extends Controller
{
private IUserSession $userSession;
private IDBConnection $db;
private LoggerInterface $logger;

/**
* Constructor.
*
* @param string $appName
* @param IRequest $request
* @param IUserSession $userSession
* @param IDBConnection $db
* @param LoggerInterface $logger
*/
public function __construct(
string $appName,
IRequest $request,
IUserSession $userSession,
IDBConnection $db,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->userSession = $userSession;
$this->db = $db;
$this->logger = $logger;
}

/**
* Add an event to a calendar.
* Handles orphaned UIDs from soft-deleted events by purging them first.
*
* @param string $calendarUri The calendar URI.
* @param string $icsContent The ICS content.
*
* @return JSONResponse
*
* @NoAdminRequired
*/
public function addEvent(string $calendarUri, string $icsContent): JSONResponse
{
$user = $this->userSession->getUser();
if ($user === null) {
return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED);
}

$userId = $user->getUID();

// Extract calendar name from URI if it's a full path
// e.g., /remote.php/dav/calendars/user/personal/ -> personal
$calendarName = $this->extractCalendarName($calendarUri);
if ($calendarName === null) {
return new JSONResponse(['error' => 'Invalid calendar URI'], Http::STATUS_BAD_REQUEST);
}

// Get calendar ID from name
$calendarId = $this->getCalendarIdByName($userId, $calendarName);
if ($calendarId === null) {
return new JSONResponse(['error' => 'Calendar not found'], Http::STATUS_NOT_FOUND);
}

// Clean ICS: remove METHOD line (REQUEST/REPLY/CANCEL are for iMIP, not CalDAV)
$cleanedIcs = preg_replace('/^METHOD:[^\r\n]*\r?\n/im', '', $icsContent);

// Extract UID from ICS
$uid = $this->extractUidFromIcs($cleanedIcs);
if ($uid === null) {
// Generate a new UID if not present
$uid = $this->generateUid();
// Insert UID into ICS after BEGIN:VEVENT
$cleanedIcs = preg_replace(
'/(BEGIN:VEVENT\r?\n)/i',
'$1UID:' . $uid . "\r\n",
$cleanedIcs,
1
);
}

// Check if event already exists (visible, not soft-deleted)
$existingEvent = $this->getExistingEvent($calendarId, $uid);
$updated = $existingEvent !== null;

// Check for orphaned UID (soft-deleted event)
$orphanedEvent = $this->getOrphanedEvent($calendarId, $uid);
if ($orphanedEvent !== null) {
$this->logger->debug('Purging orphaned calendar event', [
'calendarId' => $calendarId,
'uid' => $uid,
'objectId' => $orphanedEvent['id'],
]);
$this->purgeOrphanedEvent((int)$orphanedEvent['id']);
$updated = true; // Treat as update since we're replacing a deleted event
}

// Now add/update the event via CalDAV backend
try {
$this->saveEventToCalendar($calendarId, $uid, $cleanedIcs, $existingEvent !== null);
} catch (\Exception $e) {
$this->logger->error('Failed to save calendar event', [
'calendarId' => $calendarId,
'uid' => $uid,
'error' => $e->getMessage(),
]);
return new JSONResponse([
'error' => 'Failed to save event: ' . $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}

return new JSONResponse([
'success' => true,
'updated' => $updated,
'uid' => $uid,
]);
}

/**
* Extract calendar name from URI.
*
* @param string $uri The calendar URI.
*
* @return string|null
*/
private function extractCalendarName(string $uri): ?string
{
// Remove trailing slash
$uri = rtrim($uri, '/');

// If it's a full path, extract the last segment
if (preg_match('#/calendars/[^/]+/([^/]+)$#', $uri, $matches)) {
return $matches[1];
}

// If it's just the calendar name
if (!str_contains($uri, '/')) {
return $uri;
}

return null;
}

/**
* Get calendar ID by name for a user.
*
* @param string $userId The user ID.
* @param string $calendarName The calendar name.
*
* @return int|null
*/
private function getCalendarIdByName(string $userId, string $calendarName): ?int
{
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('calendars')
->where($qb->expr()->eq('uri', $qb->createNamedParameter($calendarName)))
->andWhere($qb->expr()->eq('principaluri', $qb->createNamedParameter('principals/users/' . $userId)));

$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

return $row !== false ? (int)$row['id'] : null;
}

/**
* Extract UID from ICS content.
*
* @param string $ics The ICS content.
*
* @return string|null
*/
private function extractUidFromIcs(string $ics): ?string
{
if (preg_match('/^UID:([^\r\n]+)/im', $ics, $matches)) {
return trim($matches[1]);
}
return null;
}

/**
* Generate a new UID.
*
* @return string
*/
private function generateUid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
);
}

/**
* Get existing event (not soft-deleted).
*
* @param int $calendarId The calendar ID.
* @param string $uid The event UID.
*
* @return array|null
*/
private function getExistingEvent(int $calendarId, string $uid): ?array
{
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'uri', 'etag')
->from('calendarobjects')
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter(0))) // 0 = regular calendar
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid)))
->andWhere($qb->expr()->isNull('deleted_at'));

$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

return $row !== false ? $row : null;
}

/**
* Get orphaned event (soft-deleted, still has UID constraint).
*
* @param int $calendarId The calendar ID.
* @param string $uid The event UID.
*
* @return array|null
*/
private function getOrphanedEvent(int $calendarId, string $uid): ?array
{
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'uri')
->from('calendarobjects')
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter(0)))
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid)))
->andWhere($qb->expr()->isNotNull('deleted_at'));

$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();

return $row !== false ? $row : null;
}

/**
* Purge an orphaned (soft-deleted) event from the database.
*
* @param int $objectId The calendar object ID.
*
* @return void
*/
private function purgeOrphanedEvent(int $objectId): void
{
// Delete from calendarobjects
$qb = $this->db->getQueryBuilder();
$qb->delete('calendarobjects')
->where($qb->expr()->eq('id', $qb->createNamedParameter($objectId)));
$qb->executeStatement();

// Also delete from calendarobjects_props if exists
$qb = $this->db->getQueryBuilder();
$qb->delete('calendarobjects_props')
->where($qb->expr()->eq('objectid', $qb->createNamedParameter($objectId)));
$qb->executeStatement();
}

/**
* Save event to calendar using CalDAV backend.
*
* @param int $calendarId The calendar ID.
* @param string $uid The event UID.
* @param string $icsContent The ICS content.
* @param bool $isUpdate Whether this is an update operation.
*
* @return void
*/
private function saveEventToCalendar(int $calendarId, string $uid, string $icsContent, bool $isUpdate): void
{
/** @var \OCA\DAV\CalDAV\CalDavBackend $caldavBackend */
$caldavBackend = \OC::$server->get(\OCA\DAV\CalDAV\CalDavBackend::class);

$uri = $uid . '.ics';

if ($isUpdate) {
// Get current etag for update
$existingEvent = $this->getExistingEvent($calendarId, $uid);
if ($existingEvent !== null) {
$caldavBackend->updateCalendarObject($calendarId, $existingEvent['uri'], $icsContent);
} else {
// Shouldn't happen, but create if missing
$caldavBackend->createCalendarObject($calendarId, $uri, $icsContent);
}
} else {
$caldavBackend->createCalendarObject($calendarId, $uri, $icsContent);
}
}
}
1 change: 1 addition & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public function index()
'emailUserId' => $credentials['userId'] ?? null,
Config::EXTERNAL_LOCATION => $roundCubeUrl,
Config::SHOW_TOP_LINE => $this->config->getAppValue(Config::SHOW_TOP_LINE),
Config::ENABLE_BRIDGE => $this->config->getAppValue(Config::ENABLE_BRIDGE),
]);

Util::addScript($this->appName, $this->assetService->getJSAsset(self::MAIN_ASSET)['asset']);
Expand Down
Loading