From 5a41dcbc68f47959babcda2a870b1769eadc25da Mon Sep 17 00:00:00 2001 From: Laurent Dinclaux Date: Thu, 5 Feb 2026 04:06:07 +1100 Subject: [PATCH] feat: add Nextcloud bridge for file and calendar integration Add optional bridge feature that enables RoundCube (via NextBridge plugin) to integrate with Nextcloud services: - Pick files from Nextcloud to attach to emails - Save email attachments to Nextcloud - Create and insert public share links - Add calendar invitations (.ics) to Nextcloud Calendar The bridge uses postMessage API for secure iframe communication. A new admin setting allows enabling/disabling this feature. The CalendarController handles orphaned UIDs from soft-deleted events, fixing the 412 Precondition Failed error when re-adding deleted events. --- README.md | 34 + appinfo/routes.php | 6 + lib/Controller/CalendarController.php | 328 +++++++++ lib/Controller/PageController.php | 1 + lib/Controller/SettingsController.php | 3 + lib/Service/Config.php | 3 + package.json | 3 +- src/AdminSettings.vue | 15 + src/RoundCubeWrapper.vue | 224 ++++++ src/RoundCubeWrapperRouteReactivity.vue | 2 + src/app.ts | 3 + src/composables/useIframeBridge.ts | 924 ++++++++++++++++++++++++ src/types/initial-state.d.ts | 1 + 13 files changed, 1546 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/CalendarController.php create mode 100644 src/composables/useIframeBridge.ts diff --git a/README.md b/README.md index 51bc2f7a..46b02195 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/appinfo/routes.php b/appinfo/routes.php index b279f7a9..1dd525f7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -66,5 +66,11 @@ 'verb' => 'GET', 'postfix' => '.all', ], + // Calendar API + [ + 'name' => 'calendar#add_event', + 'url' => '/api/calendar/event', + 'verb' => 'POST', + ], ] ]; diff --git a/lib/Controller/CalendarController.php b/lib/Controller/CalendarController.php new file mode 100644 index 00000000..678325ec --- /dev/null +++ b/lib/Controller/CalendarController.php @@ -0,0 +1,328 @@ + + * @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); + } + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 7d9e54bb..a9b0c191 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -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']); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index ec1570cc..28cd89c9 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -62,6 +62,7 @@ class SettingsController extends Controller Config::ENABLE_SSL_VERIFY => [ 'rw' => true, 'default' => Config::ENABLE_SSL_VERIFY_DEFAULT, ], Config::PERSONAL_ENCRYPTION => [ 'rw' => true, 'default' => Config::PERSONAL_ENCRYPTION_DEFAULT, ], Config::CARDDAV_PROVISIONG_TAG => [ 'rw' => true, 'default' => Config::CARDDAV_PROVISIONG_TAG_DEFAULT, ], + Config::ENABLE_BRIDGE => [ 'rw' => true, 'default' => Config::ENABLE_BRIDGE_DEFAULT, ], ]; public const EMAIL_ADDRESS = 'emailAddress'; @@ -162,6 +163,7 @@ public function setAdmin(string $setting, mixed $value, bool $force = false):Dat case Config::SHOW_TOP_LINE: case Config::ENABLE_SSL_VERIFY: case Config::PERSONAL_ENCRYPTION: + case Config::ENABLE_BRIDGE: $newValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, ['flags' => FILTER_NULL_ON_FAILURE]); if ($newValue === null) { return self::grumble($this->l->t( @@ -249,6 +251,7 @@ public function getAdmin(?string $setting = null):DataResponse case Config::SHOW_TOP_LINE: case Config::ENABLE_SSL_VERIFY: case Config::PERSONAL_ENCRYPTION: + case Config::ENABLE_BRIDGE: if ($humanValue !== null) { $humanValue = $humanValue ? $this->l->t('true') : $this->l->t('false'); } diff --git a/lib/Service/Config.php b/lib/Service/Config.php index 115dea5a..6b161d84 100644 --- a/lib/Service/Config.php +++ b/lib/Service/Config.php @@ -70,6 +70,8 @@ class Config public const PERSONAL_ENCRYPTION_DEFAULT = false; public const CARDDAV_PROVISIONG_TAG = 'cardDavProvisioningTag'; public const CARDDAV_PROVISIONG_TAG_DEFAULT = ''; + public const ENABLE_BRIDGE = 'enableBridge'; + public const ENABLE_BRIDGE_DEFAULT = false; const SETTINGS = [ self::EXTERNAL_LOCATION => self::EXTERNAL_LOCATION_DEFAULT, @@ -80,6 +82,7 @@ class Config self::ENABLE_SSL_VERIFY => self::ENABLE_SSL_VERIFY_DEFAULT, self::PERSONAL_ENCRYPTION => self::PERSONAL_ENCRYPTION_DEFAULT, self::CARDDAV_PROVISIONG_TAG => self::CARDDAV_PROVISIONG_TAG_DEFAULT, + self::ENABLE_BRIDGE => self::ENABLE_BRIDGE_DEFAULT, ]; /** @var \OCP\IUser */ diff --git a/package.json b/package.json index 17ab9d07..62b4fa0e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "@nextcloud/auth": "^2.1.0", "@nextcloud/axios": "^2.4.0", "@nextcloud/browserslist-config": "^3.0.0", - "@nextcloud/dialogs": "^6.0.0", + "@nextcloud/dialogs": "^6.4.2", + "@nextcloud/files": "^3.12.2", "@nextcloud/eslint-config": "^v8.3.0-beta.2", "@nextcloud/initial-state": "^2.0.0", "@nextcloud/l10n": "^3.2.0", diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index c8e36e09..5508e1f6 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -184,6 +184,20 @@ > {{ t(appName, 'Per-user encryption of config values.') }} + + generateRemoteUrl('dav') + '/addressbooks/users/%l') diff --git a/src/RoundCubeWrapper.vue b/src/RoundCubeWrapper.vue index 7a83e93f..63dca7f3 100644 --- a/src/RoundCubeWrapper.vue +++ b/src/RoundCubeWrapper.vue @@ -33,6 +33,31 @@ @load="loadHandler" /> + + + + + +