commit 560686040a712ae0ed8fdbaf1f2d8f11552e6d62 Author: Thomas Faour Date: Mon Feb 9 23:02:34 2026 -0500 initial commit diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 0000000..5b381a8 --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,14 @@ +SPDX-License-Identifier: AGPL-3.0-or-later + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b1a86f --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Mail Calendar Sync for Nextcloud + +Automatically apply calendar invitation responses (iMIP) received via email to your Nextcloud calendar events. + +## What it does + +When someone responds to a calendar invitation you sent (Accept, Decline, Tentative), this app automatically updates the attendee's participation status in your calendar — no manual action needed. + +### Supported iMIP methods + +| Method | Behavior | +|--------|----------| +| **REPLY** | Updates attendee status (ACCEPTED/DECLINED/TENTATIVE) on an **existing** event in your calendar. The event must already exist (matched by UID). | +| **REQUEST** | Updates an existing event with new details (time changes, etc). Optionally auto-adds new invitations to your calendar. | +| **CANCEL** | Marks an existing event as CANCELLED when the organizer cancels it. | + +### Key safety feature + +**The app always checks that the event UID from the email response already exists in your calendar before applying any changes.** This prevents stale or spoofed responses from creating phantom events. + +## Requirements + +- Nextcloud 28 or later +- [Nextcloud Mail app](https://apps.nextcloud.com/apps/mail) installed and configured with at least one account +- At least one writable calendar +- Background jobs (cron) configured and running + +## Installation + +1. Copy the `mail_calendar_sync` folder to your Nextcloud `apps/` directory: + +```bash +cp -r mail_calendar_sync /var/www/nextcloud/apps/ +``` + +2. Set proper permissions: + +```bash +chown -R www-data:www-data /var/www/nextcloud/apps/mail_calendar_sync +``` + +3. Enable the app: + +```bash +sudo -u www-data php occ app:enable mail_calendar_sync +``` + +4. Run the database migration: + +```bash +sudo -u www-data php occ migrations:migrate mail_calendar_sync +``` + +## Configuration + +Each user configures the app individually: + +1. Go to **Settings → Groupware → Mail Calendar Sync** +2. Enable the sync +3. Select which **Mail account** to scan for responses +4. Select which **Calendar** to apply updates to +5. Optionally enable **auto-accept** to automatically add new invitations +6. Click **Save** + +### Manual sync + +Click the **"Sync now"** button in settings to trigger an immediate scan instead of waiting for the background job. + +## How it works + +``` +┌─────────────────┐ ┌────────────────┐ ┌────────────────────┐ +│ Background Job │ │ Mail Service │ │ Calendar Service │ +│ (every 10 min) │────>│ (IMAP fetch) │────>│ (CalDAV lookup) │ +└─────────────────┘ └────────────────┘ └────────────────────┘ + │ │ │ + │ 1. Load user config │ 2. Find iMip messages │ 3. Search by UID + │ │ in INBOX │ in user calendars + │ │ │ + │ │ 4. Extract ICS from │ 5. Verify event exists + │ │ MIME parts │ before updating + │ │ │ + │ │ │ 6. Update attendee + │ │ │ PARTSTAT + └───────────────────────┴────────────────────────┘ +``` + +1. A **background job** runs every 10 minutes +2. For each user with sync enabled, it queries the **Mail app's database** for recent messages flagged as iMip +3. For unprocessed messages, it connects via **IMAP** to fetch the actual email and extract `text/calendar` MIME parts +4. It parses the ICS data using the **sabre/vobject** library +5. Based on the METHOD (REPLY/REQUEST/CANCEL), it: + - Searches the user's calendars for the event by **UID** + - Only proceeds if the event **already exists** (for REPLY and CANCEL) + - Updates the attendee participation status or event data + - Writes the updated event back via `ICreateFromString` +6. Each processed message is tracked to avoid re-processing + +## Architecture + +``` +mail_calendar_sync/ +├── appinfo/ +│ ├── info.xml # App metadata & dependencies +│ └── routes.php # API routes +├── lib/ +│ ├── AppInfo/ +│ │ └── Application.php # App bootstrap +│ ├── BackgroundJob/ +│ │ └── ProcessImipResponsesJob.php # Cron job (every 10 min) +│ ├── Controller/ +│ │ └── SettingsController.php # REST API for settings UI +│ ├── Db/ +│ │ ├── Config.php # User config entity +│ │ ├── ConfigMapper.php # Config DB mapper +│ │ ├── LogEntry.php # Activity log entity +│ │ ├── LogMapper.php # Log DB mapper +│ │ ├── ProcessedMessage.php # Processed msg entity +│ │ └── ProcessedMessageMapper.php # Processed msg DB mapper +│ ├── Migration/ +│ │ └── Version1000Date20250209000000.php # DB schema +│ ├── Service/ +│ │ ├── CalendarService.php # Calendar lookup & update +│ │ ├── MailService.php # IMAP fetch & ICS extraction +│ │ └── SyncService.php # Orchestrates the full flow +│ └── Settings/ +│ └── PersonalSettings.php # Settings page registration +├── templates/ +│ └── personal-settings.php # Settings page HTML +├── js/ +│ └── personal-settings.js # Settings page JavaScript +├── css/ +│ └── personal-settings.css # Settings page styles +├── composer.json +└── README.md +``` + +## Database tables + +| Table | Purpose | +|-------|---------| +| `oc_mcs_config` | Per-user configuration (enabled, mail account, calendar, auto-accept) | +| `oc_mcs_log` | Activity log of all processed responses (kept 30 days) | +| `oc_mcs_processed` | Tracks which email messages have been processed (kept 90 days) | + +## Troubleshooting + +### No messages being processed + +- Ensure the Mail app has synced recent messages (check Mail app directly) +- Verify that incoming invitation responses have `text/calendar` MIME parts +- Check that the Mail app flags messages with `$imip` (this happens during Mail sync) +- Try clicking "Sync now" in the settings to trigger an immediate scan + +### Events not being updated + +- The event **must already exist** in the selected calendar (matched by UID) +- The attendee email in the response must match an attendee in the existing event +- Check the activity log in settings for "SKIPPED" entries with explanations + +### Check server logs + +```bash +sudo -u www-data php occ log:tail --level=debug | grep -i "mail_calendar_sync\|MailCalendarSync" +``` + +## License + +AGPL-3.0-or-later diff --git a/appinfo/info.xml b/appinfo/info.xml new file mode 100644 index 0000000..c7638b6 --- /dev/null +++ b/appinfo/info.xml @@ -0,0 +1,27 @@ + + + mail_calendar_sync + Mail Calendar Sync + Automatically apply calendar invitation responses from Mail + + 1.1.0 + agpl + Mail Calendar Sync Contributors + MailCalendarSync + groupware + integration + + + + + + + OCA\MailCalendarSync\Settings\PersonalSettings + OCA\MailCalendarSync\Settings\PersonalSection + + + + OCA\MailCalendarSync\BackgroundJob\ProcessImipResponsesJob + + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..905b42f --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,14 @@ + [ + ['name' => 'settings#getConfig', 'url' => '/api/config', 'verb' => 'GET'], + ['name' => 'settings#setConfig', 'url' => '/api/config', 'verb' => 'PUT'], + ['name' => 'settings#getCalendars', 'url' => '/api/calendars', 'verb' => 'GET'], + ['name' => 'settings#getMailAccounts', 'url' => '/api/mail-accounts', 'verb' => 'GET'], + ['name' => 'settings#getLog', 'url' => '/api/log', 'verb' => 'GET'], + ['name' => 'settings#triggerSync', 'url' => '/api/trigger-sync', 'verb' => 'POST'], + ], +]; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..22afa70 --- /dev/null +++ b/composer.json @@ -0,0 +1,10 @@ +{ + "name": "nextcloud/mail_calendar_sync", + "description": "Automatically apply calendar invitation responses from Mail to Calendar", + "license": "AGPL-3.0-or-later", + "autoload": { + "psr-4": { + "OCA\\MailCalendarSync\\": "lib/" + } + } +} diff --git a/css/personal-settings.css b/css/personal-settings.css new file mode 100644 index 0000000..fc12b04 --- /dev/null +++ b/css/personal-settings.css @@ -0,0 +1,220 @@ +/* Mail Calendar Sync - Personal Settings Styles */ + +#mail-calendar-sync-settings { + max-width: 700px; +} + +#mail-calendar-sync-settings h2 { + display: flex; + align-items: center; + gap: 8px; +} + +#mail-calendar-sync-settings .settings-hint { + color: var(--color-text-maxcontrast); + margin-bottom: 20px; +} + +/* Loading */ +.mcs-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0; + color: var(--color-text-maxcontrast); +} + +/* Form fields */ +.mcs-field { + margin-bottom: 16px; +} + +.mcs-field label { + display: block; + font-weight: bold; + margin-bottom: 4px; +} + +.mcs-field label[for="mcs-enabled"], +.mcs-field label[for="mcs-auto-accept"] { + display: inline; + font-weight: normal; +} + +.mcs-field select { + width: 100%; + max-width: 400px; + min-width: 200px; +} + +.mcs-field-hint { + color: var(--color-text-maxcontrast); + font-size: 0.9em; + margin-top: 4px; + margin-bottom: 0; +} + +/* Buttons */ +#mcs-save-section { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +#mcs-save-section button.secondary { + background-color: var(--color-background-dark); + border: 1px solid var(--color-border); +} + +/* Status messages */ +.mcs-status { + font-size: 0.9em; + transition: opacity 0.3s; +} + +.mcs-status-success { + color: var(--color-success); +} + +.mcs-status-error { + color: var(--color-error); +} + +.mcs-status-warning { + color: var(--color-warning); +} + +.mcs-status-loading { + color: var(--color-text-maxcontrast); +} + +/* Activity log */ +#mcs-log-section { + margin-top: 32px; +} + +#mcs-log-section h3 { + margin-bottom: 12px; +} + +.mcs-log-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; +} + +.mcs-log-table th { + text-align: left; + padding: 8px 12px; + border-bottom: 2px solid var(--color-border); + color: var(--color-text-maxcontrast); + font-weight: 600; +} + +.mcs-log-table td { + padding: 6px 12px; + border-bottom: 1px solid var(--color-border-dark); + vertical-align: top; +} + +.mcs-log-date { + white-space: nowrap; + color: var(--color-text-maxcontrast); + font-size: 0.9em; +} + +.mcs-log-message { + color: var(--color-text-maxcontrast); + font-size: 0.9em; +} + +/* Action badges */ +.mcs-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; +} + +.mcs-badge-accepted { + background-color: #e8f5e9; + color: #2e7d32; +} + +.mcs-badge-declined { + background-color: #ffebee; + color: #c62828; +} + +.mcs-badge-tentative { + background-color: #fff3e0; + color: #ef6c00; +} + +.mcs-badge-created { + background-color: #e3f2fd; + color: #1565c0; +} + +.mcs-badge-updated { + background-color: #e8eaf6; + color: #283593; +} + +.mcs-badge-cancelled { + background-color: #fce4ec; + color: #880e4f; +} + +.mcs-badge-error { + background-color: #ffebee; + color: #b71c1c; +} + +.mcs-badge-skipped { + background-color: #f5f5f5; + color: #616161; +} + +/* Dark mode adjustments */ +[data-themes*="dark"] .mcs-badge-accepted, +.theme--dark .mcs-badge-accepted { + background-color: #1b5e20; + color: #a5d6a7; +} + +[data-themes*="dark"] .mcs-badge-declined, +.theme--dark .mcs-badge-declined { + background-color: #b71c1c; + color: #ef9a9a; +} + +[data-themes*="dark"] .mcs-badge-tentative, +.theme--dark .mcs-badge-tentative { + background-color: #e65100; + color: #ffcc02; +} + +#mcs-log-empty { + color: var(--color-text-maxcontrast); + font-style: italic; +} + +/* Sync details log */ +.mcs-sync-log { + background: var(--color-background-dark); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 12px; + font-family: monospace; + font-size: 0.85em; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; + margin-top: 8px; + color: var(--color-text-lighter); +} diff --git a/js/personal-settings.js b/js/personal-settings.js new file mode 100644 index 0000000..f218ce8 --- /dev/null +++ b/js/personal-settings.js @@ -0,0 +1,295 @@ +/** + * Mail Calendar Sync - Personal Settings JavaScript + */ +(function() { + 'use strict'; + + const BASE_URL = OC.generateUrl('/apps/mail_calendar_sync/api'); + + let currentConfig = {}; + let mailAccounts = []; + let calendars = []; + + async function init() { + try { + const [configData, accountsData, calendarsData] = await Promise.all([ + fetchJson(`${BASE_URL}/config`), + fetchJson(`${BASE_URL}/mail-accounts`), + fetchJson(`${BASE_URL}/calendars`), + ]); + + currentConfig = configData; + mailAccounts = accountsData; + calendars = calendarsData; + + renderForm(); + loadLog(); + } catch (error) { + console.error('Failed to load Mail Calendar Sync settings:', error); + showStatus('Failed to load settings: ' + error.message, 'error'); + } finally { + document.getElementById('mcs-loading').style.display = 'none'; + document.getElementById('mcs-config').style.display = ''; + } + } + + function renderForm() { + // Enabled checkbox + const enabledCheckbox = document.getElementById('mcs-enabled'); + enabledCheckbox.checked = currentConfig.enabled || false; + enabledCheckbox.addEventListener('change', onEnabledChange); + + // Mail accounts dropdown + const mailSelect = document.getElementById('mcs-mail-account'); + mailSelect.innerHTML = ''; + mailAccounts.forEach(account => { + const option = document.createElement('option'); + option.value = account.id; + option.textContent = `${account.name} (${account.email})`; + if (currentConfig.mailAccountId == account.id) { + option.selected = true; + } + mailSelect.appendChild(option); + }); + + if (mailAccounts.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No mail accounts found — configure one in the Mail app first'; + option.disabled = true; + mailSelect.appendChild(option); + } + + // Calendars dropdown + const calSelect = document.getElementById('mcs-calendar'); + calSelect.innerHTML = ''; + calendars.forEach(cal => { + const option = document.createElement('option'); + option.value = cal.uri; + option.textContent = cal.name; + if (cal.color) { + option.style.borderLeft = `4px solid ${cal.color}`; + option.style.paddingLeft = '8px'; + } + if (currentConfig.calendarUri === cal.uri) { + option.selected = true; + } + calSelect.appendChild(option); + }); + + // Sync interval dropdown + const intervalSelect = document.getElementById('mcs-sync-interval'); + const currentInterval = currentConfig.syncInterval || 600; + for (let i = 0; i < intervalSelect.options.length; i++) { + if (parseInt(intervalSelect.options[i].value) === currentInterval) { + intervalSelect.options[i].selected = true; + break; + } + } + + // Auto-accept checkbox + document.getElementById('mcs-auto-accept').checked = currentConfig.autoAccept || false; + + // Show/hide sections based on enabled state + updateSectionVisibility(currentConfig.enabled || false); + + // Bind buttons + document.getElementById('mcs-save').addEventListener('click', onSave); + document.getElementById('mcs-trigger-sync').addEventListener('click', onTriggerSync); + } + + function onEnabledChange(event) { + updateSectionVisibility(event.target.checked); + } + + function updateSectionVisibility(enabled) { + const sections = [ + 'mcs-mail-section', + 'mcs-calendar-section', + 'mcs-interval-section', + 'mcs-auto-accept-section', + 'mcs-save-section', + ]; + sections.forEach(id => { + document.getElementById(id).style.display = enabled ? '' : 'none'; + }); + } + + async function onSave() { + const saveBtn = document.getElementById('mcs-save'); + saveBtn.disabled = true; + showStatus('Saving…', 'loading'); + + const config = { + enabled: document.getElementById('mcs-enabled').checked, + mailAccountId: document.getElementById('mcs-mail-account').value || null, + calendarUri: document.getElementById('mcs-calendar').value || null, + autoAccept: document.getElementById('mcs-auto-accept').checked, + syncInterval: parseInt(document.getElementById('mcs-sync-interval').value) || 600, + }; + + if (config.enabled) { + if (!config.mailAccountId) { + showStatus('Please select a mail account', 'error'); + saveBtn.disabled = false; + return; + } + if (!config.calendarUri) { + showStatus('Please select a calendar', 'error'); + saveBtn.disabled = false; + return; + } + } + + try { + const result = await fetchJson(`${BASE_URL}/config`, { + method: 'PUT', + body: JSON.stringify(config), + headers: { + 'Content-Type': 'application/json', + }, + }); + + currentConfig = result; + showStatus('Settings saved', 'success'); + + if (config.enabled) { + document.getElementById('mcs-log-section').style.display = ''; + loadLog(); + } + } catch (error) { + console.error('Failed to save config:', error); + showStatus('Failed to save: ' + error.message, 'error'); + } finally { + saveBtn.disabled = false; + } + } + + async function onTriggerSync() { + const btn = document.getElementById('mcs-trigger-sync'); + btn.disabled = true; + showStatus('Syncing…', 'loading'); + + // Show the sync details section + const detailsSection = document.getElementById('mcs-sync-details'); + const syncLog = document.getElementById('mcs-sync-log'); + detailsSection.style.display = ''; + syncLog.textContent = 'Running sync…'; + + try { + const result = await fetchJson(`${BASE_URL}/trigger-sync`, { + method: 'POST', + }); + + const stats = result.stats || {}; + const messages = stats.messages || []; + + // Show detailed log + if (messages.length > 0) { + syncLog.textContent = messages.join('\n'); + } else { + syncLog.textContent = 'Sync completed with no messages.'; + } + + const msg = `Sync complete: ${stats.processed || 0} scanned, ${stats.updated || 0} updated, ${stats.errors || 0} errors`; + showStatus(msg, stats.errors > 0 ? 'warning' : 'success'); + + loadLog(); + } catch (error) { + console.error('Sync failed:', error); + syncLog.textContent = 'Sync failed: ' + error.message; + showStatus('Sync failed — see details below', 'error'); + } finally { + btn.disabled = false; + } + } + + async function loadLog() { + try { + const entries = await fetchJson(`${BASE_URL}/log?limit=20`); + const tbody = document.getElementById('mcs-log-body'); + const emptyMsg = document.getElementById('mcs-log-empty'); + const logSection = document.getElementById('mcs-log-section'); + + if (currentConfig.enabled) { + logSection.style.display = ''; + } + + tbody.innerHTML = ''; + + if (entries.length === 0) { + emptyMsg.style.display = ''; + return; + } + + emptyMsg.style.display = 'none'; + + entries.forEach(entry => { + const tr = document.createElement('tr'); + tr.className = `mcs-log-${entry.action.toLowerCase()}`; + + const date = entry.createdAt + ? new Date(entry.createdAt).toLocaleString() + : '—'; + + tr.innerHTML = ` + ${escapeHtml(date)} + ${escapeHtml(entry.eventSummary || entry.eventUid || '—')} + ${escapeHtml(entry.action)} + ${escapeHtml(entry.message || '—')} + `; + tbody.appendChild(tr); + }); + } catch (error) { + console.error('Failed to load log:', error); + } + } + + function showStatus(message, type) { + const statusEl = document.getElementById('mcs-status'); + statusEl.textContent = message; + statusEl.className = `mcs-status mcs-status-${type}`; + + if (type === 'success') { + setTimeout(() => { + statusEl.textContent = ''; + statusEl.className = 'mcs-status'; + }, 5000); + } + } + + async function fetchJson(url, options = {}) { + const { headers: optHeaders, ...restOptions } = options; + const response = await fetch(url, { + credentials: 'same-origin', + ...restOptions, + headers: { + 'requesttoken': OC.requestToken, + ...(optHeaders || {}), + }, + }); + + if (!response.ok) { + let errorMsg = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMsg = errorData.error; + } + } catch (e) { + // ignore parse error + } + throw new Error(errorMsg); + } + + return response.json(); + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php new file mode 100644 index 0000000..a2d7dfb --- /dev/null +++ b/lib/AppInfo/Application.php @@ -0,0 +1,27 @@ +setInterval(300); + $this->setTimeSensitivity(self::TIME_SENSITIVE); + } + + protected function run(mixed $argument): void { + $this->logger->info('Starting mail-calendar sync background job'); + + try { + $configs = $this->configMapper->findAllEnabled(); + + $totalProcessed = 0; + $totalUpdated = 0; + $totalErrors = 0; + + foreach ($configs as $config) { + $userId = $config->getUserId(); + + // Check if enough time has passed since last sync for this user + $syncInterval = $config->getSyncInterval(); + $lastSync = $config->getUpdatedAt(); + + if ($lastSync !== null && $syncInterval > 300) { + $lastSyncTime = strtotime($lastSync); + if ($lastSyncTime !== false && (time() - $lastSyncTime) < $syncInterval) { + continue; // Not time yet for this user + } + } + + try { + $stats = $this->syncService->syncForUser($userId); + $totalProcessed += $stats['processed']; + $totalUpdated += $stats['updated']; + $totalErrors += $stats['errors']; + } catch (\Throwable $e) { + $totalErrors++; + $this->logger->error('Sync failed for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + } + } + + $this->logger->info('Mail-calendar sync completed', [ + 'users' => count($configs), + 'processed' => $totalProcessed, + 'updated' => $totalUpdated, + 'errors' => $totalErrors, + ]); + + // Periodic cleanup of old records + $this->logMapper->cleanupOld(30); + $this->processedMapper->cleanupOld(90); + + } catch (\Throwable $e) { + $this->logger->error('Mail-calendar sync background job failed', [ + 'exception' => $e, + ]); + } + } +} diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php new file mode 100644 index 0000000..bbd5ef8 --- /dev/null +++ b/lib/Controller/SettingsController.php @@ -0,0 +1,215 @@ +configMapper = $configMapper; + $this->logMapper = $logMapper; + $this->calendarService = $calendarService; + $this->mailService = $mailService; + $this->syncService = $syncService; + $this->userId = $userId; + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getConfig(): JSONResponse { + try { + $config = $this->configMapper->findByUserId($this->userId); + return new JSONResponse($config->toArray()); + } catch (DoesNotExistException $e) { + return new JSONResponse([ + 'enabled' => false, + 'mailAccountId' => null, + 'calendarUri' => null, + 'autoAccept' => false, + 'syncInterval' => 600, + ]); + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Failed to load config: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function setConfig(): JSONResponse { + try { + // Parse JSON body + $body = file_get_contents('php://input'); + $data = json_decode($body, true); + + if (!is_array($data)) { + return new JSONResponse( + ['error' => 'Invalid request body'], + Http::STATUS_BAD_REQUEST + ); + } + + $enabled = !empty($data['enabled']); + $mailAccountId = $data['mailAccountId'] ?? null; + $calendarUri = $data['calendarUri'] ?? null; + $autoAccept = !empty($data['autoAccept']); + $syncInterval = (int)($data['syncInterval'] ?? 600); + + // Clamp sync interval to valid range (5 min to 24 hours) + $syncInterval = max(300, min(86400, $syncInterval)); + + // Normalize empty strings to null + if ($mailAccountId === '' || $mailAccountId === '0') { + $mailAccountId = null; + } + if ($calendarUri === '') { + $calendarUri = null; + } + + try { + $config = $this->configMapper->findByUserId($this->userId); + } catch (DoesNotExistException $e) { + $config = new Config(); + $config->setUserId($this->userId); + $config->setCreatedAt(date('Y-m-d H:i:s')); + } + + $config->setEnabled($enabled ? 1 : 0); + $config->setMailAccountId($mailAccountId !== null ? (int)$mailAccountId : null); + $config->setCalendarUri($calendarUri); + $config->setAutoAccept($autoAccept ? 1 : 0); + $config->setSyncInterval($syncInterval); + $config->setUpdatedAt(date('Y-m-d H:i:s')); + + if ($config->getId() === null) { + $config = $this->configMapper->insert($config); + } else { + $config = $this->configMapper->update($config); + } + + return new JSONResponse($config->toArray()); + + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Failed to save: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getCalendars(): JSONResponse { + try { + $calendars = $this->calendarService->getWritableCalendars($this->userId); + return new JSONResponse($calendars); + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Failed to load calendars: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getMailAccounts(): JSONResponse { + try { + $accounts = $this->mailService->getMailAccounts($this->userId); + return new JSONResponse($accounts); + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Failed to load mail accounts: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getLog(): JSONResponse { + try { + $limit = (int)($this->request->getParam('limit', '50')); + $offset = (int)($this->request->getParam('offset', '0')); + + $entries = $this->logMapper->findByUserId($this->userId, $limit, $offset); + $data = array_map(fn($entry) => $entry->toArray(), $entries); + + return new JSONResponse($data); + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Failed to load log: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * Manually trigger a sync for the current user. + */ + public function triggerSync(): JSONResponse { + try { + $stats = $this->syncService->syncForUser($this->userId); + return new JSONResponse([ + 'status' => 'ok', + 'stats' => $stats, + ]); + } catch (\Throwable $e) { + return new JSONResponse( + ['error' => 'Sync failed: ' . $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } +} diff --git a/lib/Db/Config.php b/lib/Db/Config.php new file mode 100644 index 0000000..ffc704d --- /dev/null +++ b/lib/Db/Config.php @@ -0,0 +1,64 @@ +addType('enabled', 'integer'); + $this->addType('mailAccountId', 'integer'); + $this->addType('autoAccept', 'integer'); + $this->addType('syncInterval', 'integer'); + } + + public function isEnabled(): bool { + return $this->getEnabled() === 1; + } + + public function isAutoAccept(): bool { + return $this->getAutoAccept() === 1; + } + + public function toArray(): array { + return [ + 'id' => $this->getId(), + 'userId' => $this->getUserId(), + 'enabled' => $this->isEnabled(), + 'mailAccountId' => $this->getMailAccountId(), + 'calendarUri' => $this->getCalendarUri(), + 'autoAccept' => $this->isAutoAccept(), + 'syncInterval' => $this->getSyncInterval(), + ]; + } +} diff --git a/lib/Db/ConfigMapper.php b/lib/Db/ConfigMapper.php new file mode 100644 index 0000000..5e9c2d2 --- /dev/null +++ b/lib/Db/ConfigMapper.php @@ -0,0 +1,44 @@ + + */ +class ConfigMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mcs_config', Config::class); + } + + /** + * @throws DoesNotExistException + */ + public function findByUserId(string $userId): Config { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + return $this->findEntity($qb); + } + + /** + * @return Config[] + */ + public function findAllEnabled(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('enabled', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))); + + return $this->findEntities($qb); + } +} diff --git a/lib/Db/LogEntry.php b/lib/Db/LogEntry.php new file mode 100644 index 0000000..95fdc7f --- /dev/null +++ b/lib/Db/LogEntry.php @@ -0,0 +1,50 @@ + $this->getId(), + 'eventUid' => $this->getEventUid(), + 'eventSummary' => $this->getEventSummary(), + 'action' => $this->getAction(), + 'attendeeEmail' => $this->getAttendeeEmail(), + 'fromEmail' => $this->getFromEmail(), + 'message' => $this->getMessage(), + 'createdAt' => $this->getCreatedAt(), + ]; + } +} diff --git a/lib/Db/LogMapper.php b/lib/Db/LogMapper.php new file mode 100644 index 0000000..3d75f50 --- /dev/null +++ b/lib/Db/LogMapper.php @@ -0,0 +1,73 @@ + + */ +class LogMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mcs_log', LogEntry::class); + } + + /** + * Get recent log entries for a user. + * + * @return LogEntry[] + */ + public function findByUserId(string $userId, int $limit = 50, int $offset = 0): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->orderBy('created_at', 'DESC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + return $this->findEntities($qb); + } + + /** + * Create a log entry. + */ + public function log( + string $userId, + string $eventUid, + string $action, + ?string $eventSummary = null, + ?string $attendeeEmail = null, + ?string $fromEmail = null, + ?string $message = null, + ): LogEntry { + $entry = new LogEntry(); + $entry->setUserId($userId); + $entry->setEventUid($eventUid); + $entry->setAction($action); + $entry->setEventSummary($eventSummary); + $entry->setAttendeeEmail($attendeeEmail); + $entry->setFromEmail($fromEmail); + $entry->setMessage($message); + $entry->setCreatedAt(date('Y-m-d H:i:s')); + + return $this->insert($entry); + } + + /** + * Delete old log entries (older than 30 days). + */ + public function cleanupOld(int $days = 30): int { + $qb = $this->db->getQueryBuilder(); + $cutoff = (new \DateTime("-{$days} days"))->format('Y-m-d H:i:s'); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('created_at', $qb->createNamedParameter($cutoff))); + + return $qb->executeStatement(); + } +} diff --git a/lib/Db/ProcessedMessage.php b/lib/Db/ProcessedMessage.php new file mode 100644 index 0000000..ad873e5 --- /dev/null +++ b/lib/Db/ProcessedMessage.php @@ -0,0 +1,29 @@ +addType('mailAccountId', 'integer'); + } +} diff --git a/lib/Db/ProcessedMessageMapper.php b/lib/Db/ProcessedMessageMapper.php new file mode 100644 index 0000000..cc24aa6 --- /dev/null +++ b/lib/Db/ProcessedMessageMapper.php @@ -0,0 +1,62 @@ + + */ +class ProcessedMessageMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mcs_processed', ProcessedMessage::class); + } + + /** + * Check if a message has already been processed. + */ + public function isProcessed(string $userId, int $mailAccountId, string $messageId): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('mail_account_id', $qb->createNamedParameter($mailAccountId))) + ->andWhere($qb->expr()->eq('message_id', $qb->createNamedParameter($messageId))); + + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count > 0; + } + + /** + * Mark a message as processed. + */ + public function markProcessed(string $userId, int $mailAccountId, string $messageId): ProcessedMessage { + $entity = new ProcessedMessage(); + $entity->setUserId($userId); + $entity->setMailAccountId($mailAccountId); + $entity->setMessageId($messageId); + $entity->setProcessedAt(date('Y-m-d H:i:s')); + + return $this->insert($entity); + } + + /** + * Clean up old processed message records (older than N days). + */ + public function cleanupOld(int $days = 90): int { + $qb = $this->db->getQueryBuilder(); + $cutoff = (new \DateTime("-{$days} days"))->format('Y-m-d H:i:s'); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('processed_at', $qb->createNamedParameter($cutoff))); + + return $qb->executeStatement(); + } +} diff --git a/lib/Migration/Version1000Date20250209000000.php b/lib/Migration/Version1000Date20250209000000.php new file mode 100644 index 0000000..ef15689 --- /dev/null +++ b/lib/Migration/Version1000Date20250209000000.php @@ -0,0 +1,143 @@ +hasTable('mcs_config')) { + $table = $schema->createTable('mcs_config'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('enabled', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('mail_account_id', Types::BIGINT, [ + 'notnull' => false, + 'unsigned' => true, + ]); + $table->addColumn('calendar_uri', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('auto_accept', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('created_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('updated_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id'], 'mcs_config_user_idx'); + } + + // Activity log table + if (!$schema->hasTable('mcs_log')) { + $table = $schema->createTable('mcs_log'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('event_uid', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('event_summary', Types::STRING, [ + 'notnull' => false, + 'length' => 512, + ]); + $table->addColumn('action', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'comment' => 'ACCEPTED, DECLINED, TENTATIVE, CREATED, UPDATED, CANCELLED, ERROR', + ]); + $table->addColumn('attendee_email', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('from_email', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('message', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('created_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id', 'created_at'], 'mcs_log_user_date_idx'); + } + + // Track which messages we have already processed + if (!$schema->hasTable('mcs_processed')) { + $table = $schema->createTable('mcs_processed'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('mail_account_id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('message_id', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + 'comment' => 'IMAP message-id header value', + ]); + $table->addColumn('processed_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'mail_account_id', 'message_id'], 'mcs_proc_unique_idx'); + $table->addIndex(['user_id'], 'mcs_proc_user_idx'); + } + + return $schema; + } +} diff --git a/lib/Migration/Version1003Date20250210000000.php b/lib/Migration/Version1003Date20250210000000.php new file mode 100644 index 0000000..7b3219a --- /dev/null +++ b/lib/Migration/Version1003Date20250210000000.php @@ -0,0 +1,147 @@ +hasTable('mcs_config')) { + $table = $schema->createTable('mcs_config'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('enabled', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('mail_account_id', Types::BIGINT, [ + 'notnull' => false, + 'unsigned' => true, + ]); + $table->addColumn('calendar_uri', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('auto_accept', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('sync_interval', Types::INTEGER, [ + 'notnull' => true, + 'default' => 600, + 'unsigned' => true, + ]); + $table->addColumn('created_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('updated_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id'], 'mcs_config_user_idx'); + } + + if (!$schema->hasTable('mcs_log')) { + $table = $schema->createTable('mcs_log'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('event_uid', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('event_summary', Types::STRING, [ + 'notnull' => false, + 'length' => 512, + ]); + $table->addColumn('action', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('attendee_email', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('from_email', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('message', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('created_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id', 'created_at'], 'mcs_log_user_date_idx'); + } + + if (!$schema->hasTable('mcs_processed')) { + $table = $schema->createTable('mcs_processed'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('mail_account_id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('message_id', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + ]); + $table->addColumn('processed_at', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'mail_account_id', 'message_id'], 'mcs_proc_unique_idx'); + $table->addIndex(['user_id'], 'mcs_proc_user_idx'); + } + + return $schema; + } +} diff --git a/lib/Service/CalendarService.php b/lib/Service/CalendarService.php new file mode 100644 index 0000000..6685d99 --- /dev/null +++ b/lib/Service/CalendarService.php @@ -0,0 +1,446 @@ + + */ + public function getWritableCalendars(string $userId): array { + $principal = 'principals/users/' . $userId; + $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); + + $result = []; + foreach ($calendars as $calendar) { + if ($calendar instanceof ICreateFromString) { + $result[] = [ + 'uri' => $calendar->getUri(), + 'name' => $calendar->getDisplayName() ?? $calendar->getUri(), + 'color' => $calendar->getDisplayColor(), + ]; + } + } + + return $result; + } + + /** + * Search for an event by its UID across a user's calendars or in a specific calendar. + * + * @return array|null Array with 'calendar' (ICreateFromString) and 'calendarData' (string ICS) or null + */ + public function findEventByUid(string $userId, string $eventUid, ?string $calendarUri = null): ?array { + $principal = 'principals/users/' . $userId; + + // Use the calendar search API to find the event by UID + $query = $this->calendarManager->newQuery($principal); + + if ($calendarUri !== null) { + $query->addSearchCalendar($calendarUri); + } + + // Search by UID-based property + $query->setSearchPattern($eventUid); + $query->addSearchProperty('UID'); + $query->setLimit(5); + + $results = $this->calendarManager->searchForPrincipal($query); + + foreach ($results as $result) { + $calData = $result['calendardata'] ?? null; + if ($calData === null) { + continue; + } + + // Parse and verify UID matches + try { + $vcalendar = Reader::read($calData); + $vevent = $vcalendar->VEVENT ?? null; + if ($vevent === null) { + continue; + } + + $foundUid = (string)($vevent->UID ?? ''); + if ($foundUid === $eventUid) { + // Find the matching writable calendar + $calendar = $this->getWritableCalendarByUri($userId, $result['calendar-uri'] ?? ''); + if ($calendar !== null) { + return [ + 'calendar' => $calendar, + 'calendarData' => $calData, + 'vcalendar' => $vcalendar, + 'calendarUri' => $result['calendar-uri'] ?? '', + ]; + } + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to parse calendar data while searching for UID', [ + 'uid' => $eventUid, + 'exception' => $e, + ]); + } + } + + return null; + } + + /** + * Process an iMIP REPLY - update attendee status on an existing event. + */ + public function processReply(string $userId, VCalendar $replyVcal, string $fromEmail): bool { + $replyEvent = $replyVcal->VEVENT ?? null; + if ($replyEvent === null) { + $this->logger->debug('No VEVENT in REPLY'); + return false; + } + + $eventUid = (string)($replyEvent->UID ?? ''); + if ($eventUid === '') { + $this->logger->debug('No UID in REPLY VEVENT'); + return false; + } + + $summary = (string)($replyEvent->SUMMARY ?? 'Unknown event'); + + // Find the existing event in the user's calendar + $existing = $this->findEventByUid($userId, $eventUid); + if ($existing === null) { + $this->logMapper->log( + $userId, + $eventUid, + 'SKIPPED', + $summary, + null, + $fromEmail, + 'Event not found in any calendar — REPLY ignored', + ); + $this->logger->info('Event UID not found in calendar, skipping REPLY', [ + 'uid' => $eventUid, + 'user' => $userId, + ]); + return false; + } + + /** @var VCalendar $existingVcal */ + $existingVcal = $existing['vcalendar']; + $existingEvent = $existingVcal->VEVENT; + + // Extract attendee status from the reply + $updated = false; + if (isset($replyEvent->ATTENDEE)) { + foreach ($replyEvent->ATTENDEE as $replyAttendee) { + $replyEmail = $this->extractEmail((string)$replyAttendee->getValue()); + $partstat = $replyAttendee['PARTSTAT'] ? (string)$replyAttendee['PARTSTAT'] : null; + + if ($replyEmail === '' || $partstat === null) { + continue; + } + + // Find and update the matching attendee in the existing event + if (isset($existingEvent->ATTENDEE)) { + foreach ($existingEvent->ATTENDEE as $existingAttendee) { + $existingEmail = $this->extractEmail((string)$existingAttendee->getValue()); + if (strtolower($existingEmail) === strtolower($replyEmail)) { + $existingAttendee['PARTSTAT'] = $partstat; + $updated = true; + + $this->logMapper->log( + $userId, + $eventUid, + $partstat, + $summary, + $replyEmail, + $fromEmail, + "Attendee {$replyEmail} responded {$partstat}", + ); + + $this->logger->info('Updated attendee status', [ + 'uid' => $eventUid, + 'attendee' => $replyEmail, + 'partstat' => $partstat, + 'user' => $userId, + ]); + } + } + } + } + } + + if (!$updated) { + $this->logMapper->log( + $userId, + $eventUid, + 'SKIPPED', + $summary, + null, + $fromEmail, + 'No matching attendee found in existing event', + ); + return false; + } + + // Write the updated event back to the calendar + try { + /** @var ICreateFromString $calendar */ + $calendar = $existing['calendar']; + $icsFilename = $eventUid . '.ics'; + $calendar->createFromString($icsFilename, $existingVcal->serialize()); + + $this->logger->info('Successfully updated calendar event from REPLY', [ + 'uid' => $eventUid, + 'user' => $userId, + ]); + + return true; + } catch (\Throwable $e) { + $this->logMapper->log( + $userId, + $eventUid, + 'ERROR', + $summary, + null, + $fromEmail, + 'Failed to write updated event: ' . $e->getMessage(), + ); + $this->logger->error('Failed to write updated event to calendar', [ + 'uid' => $eventUid, + 'user' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Process an iMIP REQUEST - add new event or update existing one in the user's calendar. + * Only processes if auto_accept is enabled or the event already exists. + */ + public function processRequest( + string $userId, + VCalendar $requestVcal, + string $fromEmail, + ?string $targetCalendarUri = null, + bool $autoAccept = false, + ): bool { + $requestEvent = $requestVcal->VEVENT ?? null; + if ($requestEvent === null) { + return false; + } + + $eventUid = (string)($requestEvent->UID ?? ''); + if ($eventUid === '') { + return false; + } + + $summary = (string)($requestEvent->SUMMARY ?? 'Unknown event'); + + // Check if this event already exists + $existing = $this->findEventByUid($userId, $eventUid, $targetCalendarUri); + + if ($existing !== null) { + // Event exists — update it with the new data (e.g. time change) + try { + /** @var ICreateFromString $calendar */ + $calendar = $existing['calendar']; + $icsFilename = $eventUid . '.ics'; + $calendar->createFromString($icsFilename, $requestVcal->serialize()); + + $this->logMapper->log( + $userId, + $eventUid, + 'UPDATED', + $summary, + null, + $fromEmail, + 'Existing event updated from incoming REQUEST', + ); + + return true; + } catch (\Throwable $e) { + $this->logMapper->log( + $userId, + $eventUid, + 'ERROR', + $summary, + null, + $fromEmail, + 'Failed to update existing event: ' . $e->getMessage(), + ); + return false; + } + } + + // Event doesn't exist yet + if ($autoAccept && $targetCalendarUri !== null) { + // Create the event in the target calendar + $calendar = $this->getWritableCalendarByUri($userId, $targetCalendarUri); + if ($calendar === null) { + $this->logMapper->log( + $userId, + $eventUid, + 'ERROR', + $summary, + null, + $fromEmail, + "Target calendar '{$targetCalendarUri}' not found or not writable", + ); + return false; + } + + try { + $icsFilename = $eventUid . '.ics'; + $calendar->createFromString($icsFilename, $requestVcal->serialize()); + + $this->logMapper->log( + $userId, + $eventUid, + 'CREATED', + $summary, + null, + $fromEmail, + 'New event auto-added to calendar from REQUEST', + ); + + return true; + } catch (\Throwable $e) { + $this->logMapper->log( + $userId, + $eventUid, + 'ERROR', + $summary, + null, + $fromEmail, + 'Failed to create event: ' . $e->getMessage(), + ); + return false; + } + } + + // Not auto-accepting and event doesn't exist — skip + $this->logMapper->log( + $userId, + $eventUid, + 'SKIPPED', + $summary, + null, + $fromEmail, + 'New invitation REQUEST not auto-accepted (enable auto-accept to add new events)', + ); + + return false; + } + + /** + * Process an iMIP CANCEL - remove or cancel an existing event. + */ + public function processCancel(string $userId, VCalendar $cancelVcal, string $fromEmail): bool { + $cancelEvent = $cancelVcal->VEVENT ?? null; + if ($cancelEvent === null) { + return false; + } + + $eventUid = (string)($cancelEvent->UID ?? ''); + if ($eventUid === '') { + return false; + } + + $summary = (string)($cancelEvent->SUMMARY ?? 'Unknown event'); + + $existing = $this->findEventByUid($userId, $eventUid); + if ($existing === null) { + $this->logMapper->log( + $userId, + $eventUid, + 'SKIPPED', + $summary, + null, + $fromEmail, + 'CANCEL received but event not found in calendar', + ); + return false; + } + + // Mark the existing event as CANCELLED + /** @var VCalendar $existingVcal */ + $existingVcal = $existing['vcalendar']; + $existingEvent = $existingVcal->VEVENT; + $existingEvent->STATUS = 'CANCELLED'; + + try { + /** @var ICreateFromString $calendar */ + $calendar = $existing['calendar']; + $icsFilename = $eventUid . '.ics'; + $calendar->createFromString($icsFilename, $existingVcal->serialize()); + + $this->logMapper->log( + $userId, + $eventUid, + 'CANCELLED', + $summary, + null, + $fromEmail, + 'Event marked as CANCELLED per organizer request', + ); + + return true; + } catch (\Throwable $e) { + $this->logMapper->log( + $userId, + $eventUid, + 'ERROR', + $summary, + null, + $fromEmail, + 'Failed to cancel event: ' . $e->getMessage(), + ); + return false; + } + } + + /** + * Extract email address from a mailto: URI or plain email. + */ + private function extractEmail(string $value): string { + $value = trim($value); + if (str_starts_with(strtolower($value), 'mailto:')) { + $value = substr($value, 7); + } + return trim($value); + } + + /** + * Get a writable calendar by URI for a user. + */ + private function getWritableCalendarByUri(string $userId, string $uri): ?ICreateFromString { + $principal = 'principals/users/' . $userId; + $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); + + foreach ($calendars as $calendar) { + if ($calendar instanceof ICreateFromString && $calendar->getUri() === $uri) { + return $calendar; + } + } + + return null; + } +} diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php new file mode 100644 index 0000000..c546164 --- /dev/null +++ b/lib/Service/MailService.php @@ -0,0 +1,333 @@ + + */ + public function getMailAccounts(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'name', 'email') + ->from('mail_accounts') + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + $result = $qb->executeQuery(); + $accounts = []; + + while ($row = $result->fetch()) { + $accounts[] = [ + 'id' => (int)$row['id'], + 'name' => $row['name'] ?? $row['email'], + 'email' => $row['email'], + ]; + } + + $result->closeCursor(); + return $accounts; + } + + /** + * Get IMAP connection details for a mail account. + */ + public function getMailAccountDetails(int $accountId, string $userId): ?array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('mail_accounts') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($accountId))) + ->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return $row === false ? null : $row; + } + + /** + * Find recent messages with iMIP/calendar content in the INBOX. + * + * The Mail app uses `imip_message` (boolean) column on oc_mail_messages + * to flag messages containing calendar data. + * + * @return array + */ + public function findRecentImipMessages(string $userId, int $mailAccountId, int $lookbackDays = 7): array { + // First, find the INBOX mailbox ID for this account + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from('mail_mailboxes') + ->where($qb->expr()->eq('account_id', $qb->createNamedParameter($mailAccountId))) + ->andWhere($qb->expr()->like( + $qb->func()->lower('name'), + $qb->createNamedParameter('inbox') + )); + + $result = $qb->executeQuery(); + $inboxRow = $result->fetch(); + $result->closeCursor(); + + if ($inboxRow === false) { + $this->logger->debug('No INBOX found for mail account', [ + 'accountId' => $mailAccountId, + 'userId' => $userId, + ]); + return []; + } + + $inboxId = (int)$inboxRow['id']; + + // Find recent messages flagged as iMip by the Mail app + // The column is called `imip_message` (boolean), NOT `flag_imip` + $cutoff = new \DateTime("-{$lookbackDays} days"); + $qb = $this->db->getQueryBuilder(); + $qb->select('m.uid', 'm.message_id', 'm.subject', 'm.sent_at') + ->from('mail_messages', 'm') + ->where($qb->expr()->eq('m.mailbox_id', $qb->createNamedParameter($inboxId))) + ->andWhere($qb->expr()->gte('m.sent_at', $qb->createNamedParameter( + $cutoff->getTimestamp() + ))) + ->andWhere($qb->expr()->eq('m.imip_message', $qb->createNamedParameter( + true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL + ))) + ->orderBy('m.sent_at', 'DESC') + ->setMaxResults(100); + + $result = $qb->executeQuery(); + $messages = []; + + while ($row = $result->fetch()) { + $messageId = $row['message_id'] ?? ''; + + // Skip already-processed messages + if ($messageId !== '' && $this->processedMapper->isProcessed($userId, $mailAccountId, $messageId)) { + continue; + } + + $messages[] = [ + 'uid' => (int)$row['uid'], + 'messageId' => $messageId, + 'subject' => $row['subject'] ?? '', + 'mailboxId' => $inboxId, + ]; + } + + $result->closeCursor(); + return $messages; + } + + /** + * Fetch the ICS data from a message via IMAP using the Horde library. + * + * This connects to IMAP using the credentials from the Mail app's database, + * fetches the message, and extracts any text/calendar MIME parts. + * + * NOTE: This is READ-ONLY. Emails are never deleted or modified. + * + * @return array Extracted ICS data + */ + public function fetchIcsFromMessage(int $mailAccountId, string $userId, int $mailboxId, int $messageUid): array { + $accountDetails = $this->getMailAccountDetails($mailAccountId, $userId); + if ($accountDetails === null) { + throw new \RuntimeException("Mail account {$mailAccountId} not found for user {$userId}"); + } + + // Decrypt the stored password + $password = $this->decryptPassword($accountDetails['inbound_password'] ?? ''); + if (empty($password)) { + throw new \RuntimeException('Failed to decrypt IMAP password for account ' . $mailAccountId); + } + + $imapHost = $accountDetails['inbound_host'] ?? ''; + $imapPort = (int)($accountDetails['inbound_port'] ?? 993); + $imapUser = $accountDetails['inbound_user'] ?? $accountDetails['email']; + $imapSsl = $this->mapSslMode($accountDetails['inbound_ssl_mode'] ?? 'ssl'); + + if (empty($imapHost)) { + throw new \RuntimeException('No IMAP host configured for account ' . $mailAccountId); + } + + $this->logger->debug('Connecting to IMAP', [ + 'host' => $imapHost, + 'port' => $imapPort, + 'user' => $imapUser, + 'ssl' => $imapSsl, + ]); + + // Build IMAP connection using Horde (same library Nextcloud Mail uses) + $imapParams = [ + 'username' => $imapUser, + 'password' => $password, + 'hostspec' => $imapHost, + 'port' => $imapPort, + 'secure' => $imapSsl, + ]; + + $client = new \Horde_Imap_Client_Socket($imapParams); + + try { + // Get the mailbox name + $mailboxName = $this->getMailboxName($mailboxId); + if ($mailboxName === null) { + throw new \RuntimeException("Mailbox ID {$mailboxId} not found in database"); + } + + $mailbox = new \Horde_Imap_Client_Mailbox($mailboxName); + + // Fetch the full message body structure + $query = new \Horde_Imap_Client_Fetch_Query(); + $query->structure(); + $query->envelope(); + $query->fullText(['peek' => true]); + + $ids = new \Horde_Imap_Client_Ids([$messageUid]); + $fetchResults = $client->fetch($mailbox, $query, ['ids' => $ids]); + + $icsResults = []; + + foreach ($fetchResults as $fetchData) { + $envelope = $fetchData->getEnvelope(); + $fromAddresses = $envelope->from ?? []; + $from = ''; + if (count($fromAddresses) > 0) { + $firstFrom = $fromAddresses[0]; + $from = ($firstFrom->bare_address ?? ''); + } + + // Get the full message text and parse for ICS parts + $fullText = $fetchData->getFullMsg(); + if ($fullText === null) { + continue; + } + + // Parse MIME structure to find text/calendar parts + $mimeMessage = \Horde_Mime_Part::parseMessage($fullText); + $icsParts = $this->findCalendarParts($mimeMessage); + + foreach ($icsParts as $icsPart) { + $icsData = $icsPart->getContents(); + if (empty($icsData)) { + continue; + } + + // Determine the METHOD from the ICS data + $method = 'REQUEST'; // default + try { + $vcal = Reader::read($icsData); + if (isset($vcal->METHOD)) { + $method = strtoupper((string)$vcal->METHOD); + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to parse ICS to determine method', [ + 'exception' => $e, + ]); + } + + $icsResults[] = [ + 'ics' => $icsData, + 'method' => $method, + 'from' => $from, + ]; + } + } + + return $icsResults; + + } finally { + try { + $client->logout(); + } catch (\Throwable $e) { + // ignore logout errors + } + } + } + + /** + * Recursively find text/calendar MIME parts. + * + * @return \Horde_Mime_Part[] + */ + private function findCalendarParts(\Horde_Mime_Part $part): array { + $results = []; + $contentType = strtolower($part->getType()); + + if ($contentType === 'text/calendar' || $contentType === 'application/ics') { + $results[] = $part; + } + + foreach ($part->getParts() as $subPart) { + $results = array_merge($results, $this->findCalendarParts($subPart)); + } + + return $results; + } + + /** + * Get mailbox name by ID from the Mail app's database. + */ + private function getMailboxName(int $mailboxId): ?string { + $qb = $this->db->getQueryBuilder(); + $qb->select('name') + ->from('mail_mailboxes') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($mailboxId))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return $row ? ($row['name'] ?? null) : null; + } + + /** + * Decrypt the stored password using Nextcloud's crypto. + */ + private function decryptPassword(string $encrypted): string { + if (empty($encrypted)) { + return ''; + } + + try { + $crypto = \OC::$server->getCrypto(); + return $crypto->decrypt($encrypted); + } catch (\Throwable $e) { + $this->logger->warning('Failed to decrypt mail password', [ + 'exception' => $e, + ]); + return ''; + } + } + + /** + * Map Nextcloud Mail's SSL mode strings to Horde constants. + */ + private function mapSslMode(string $mode): string { + return match (strtolower($mode)) { + 'ssl' => 'ssl', + 'tls', 'starttls' => 'tls', + default => '', + }; + } +} diff --git a/lib/Service/SyncService.php b/lib/Service/SyncService.php new file mode 100644 index 0000000..dcee590 --- /dev/null +++ b/lib/Service/SyncService.php @@ -0,0 +1,211 @@ + 0, 'updated' => 0, 'errors' => 0, 'messages' => []]; + + try { + $config = $this->configMapper->findByUserId($userId); + } catch (DoesNotExistException $e) { + $stats['messages'][] = 'No configuration found. Please save your settings first.'; + return $stats; + } + + if (!$config->isEnabled()) { + $stats['messages'][] = 'Sync is disabled.'; + return $stats; + } + + $mailAccountId = $config->getMailAccountId(); + $calendarUri = $config->getCalendarUri(); + $autoAccept = $config->isAutoAccept(); + + if ($mailAccountId === null || $calendarUri === null) { + $stats['messages'][] = 'Incomplete configuration — please select both a mail account and calendar.'; + return $stats; + } + + $this->logger->info('Starting mail-calendar sync', [ + 'userId' => $userId, + 'mailAccountId' => $mailAccountId, + 'calendarUri' => $calendarUri, + ]); + + // Find recent iMip messages + try { + $messages = $this->mailService->findRecentImipMessages($userId, $mailAccountId); + $stats['messages'][] = 'Found ' . count($messages) . ' unprocessed iMIP message(s) in last 7 days.'; + } catch (\Throwable $e) { + $stats['errors']++; + $stats['messages'][] = 'Failed to query mail: ' . $e->getMessage(); + $this->logger->error('Failed to find iMIP messages', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return $stats; + } + + foreach ($messages as $message) { + $stats['processed']++; + $messageId = $message['messageId']; + + try { + // Fetch ICS data from the actual email via IMAP (READ-ONLY) + $icsResults = $this->mailService->fetchIcsFromMessage( + $mailAccountId, + $userId, + $message['mailboxId'], + $message['uid'], + ); + + if (empty($icsResults)) { + $stats['messages'][] = "No ICS data found in message: {$message['subject']}"; + + // Still mark as processed so we don't retry + if ($messageId !== '') { + $this->processedMapper->markProcessed($userId, $mailAccountId, $messageId); + } + continue; + } + + foreach ($icsResults as $icsResult) { + $this->processIcs( + $userId, + $icsResult['ics'], + $icsResult['method'], + $icsResult['from'], + $calendarUri, + $autoAccept, + $stats, + ); + } + + // Mark message as processed + if ($messageId !== '') { + $this->processedMapper->markProcessed($userId, $mailAccountId, $messageId); + } + + } catch (\Throwable $e) { + $stats['errors']++; + $stats['messages'][] = "Error processing '{$message['subject']}': " . $e->getMessage(); + $this->logger->error('Error processing message', [ + 'messageId' => $messageId, + 'userId' => $userId, + 'exception' => $e, + ]); + } + } + + $this->logger->info('Mail-calendar sync completed', [ + 'userId' => $userId, + 'stats' => $stats, + ]); + + return $stats; + } + + /** + * Process a single ICS payload. + */ + private function processIcs( + string $userId, + string $icsData, + string $method, + string $fromEmail, + string $calendarUri, + bool $autoAccept, + array &$stats, + ): void { + try { + $vcalendar = Reader::read($icsData); + } catch (\Throwable $e) { + $this->logger->warning('Failed to parse ICS data', ['exception' => $e]); + $stats['errors']++; + $stats['messages'][] = 'Failed to parse ICS: ' . $e->getMessage(); + return; + } + + $vevent = $vcalendar->VEVENT ?? null; + $summary = $vevent ? (string)($vevent->SUMMARY ?? 'Unknown') : 'Unknown'; + $uid = $vevent ? (string)($vevent->UID ?? '') : ''; + + $success = match ($method) { + 'REPLY' => $this->calendarService->processReply($userId, $vcalendar, $fromEmail), + 'REQUEST' => $this->calendarService->processRequest( + $userId, $vcalendar, $fromEmail, $calendarUri, $autoAccept + ), + 'CANCEL' => $this->calendarService->processCancel($userId, $vcalendar, $fromEmail), + default => false, + }; + + if ($success) { + $stats['updated']++; + $stats['messages'][] = "{$method}: Updated '{$summary}' from {$fromEmail}"; + } else { + $stats['messages'][] = "{$method}: No action for '{$summary}' (event may not exist in calendar)"; + } + } + + /** + * Run sync for all enabled users. + */ + public function syncAll(): array { + $configs = $this->configMapper->findAllEnabled(); + $results = []; + + foreach ($configs as $config) { + $userId = $config->getUserId(); + try { + $results[$userId] = $this->syncForUser($userId); + } catch (\Throwable $e) { + $this->logger->error('Sync failed for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + $results[$userId] = [ + 'processed' => 0, + 'updated' => 0, + 'errors' => 1, + 'messages' => ['Sync failed: ' . $e->getMessage()], + ]; + } + } + + return $results; + } +} diff --git a/lib/Settings/PersonalSection.php b/lib/Settings/PersonalSection.php new file mode 100644 index 0000000..38c0afd --- /dev/null +++ b/lib/Settings/PersonalSection.php @@ -0,0 +1,38 @@ +l->t('Mail Calendar Sync'); + } + + public function getPriority(): int { + return 80; + } + + public function getIcon(): string { + try { + return $this->urlGenerator->imagePath('core', 'places/calendar.svg'); + } catch (\RuntimeException $e) { + return ''; + } + } +} diff --git a/lib/Settings/PersonalSettings.php b/lib/Settings/PersonalSettings.php new file mode 100644 index 0000000..ebbb63e --- /dev/null +++ b/lib/Settings/PersonalSettings.php @@ -0,0 +1,24 @@ + + +
+

+ t('Mail Calendar Sync')); ?> +

+ +

+ t('Automatically apply calendar invitation responses (accept, decline, tentative) received via email to your calendar events. Emails are never deleted or modified.')); ?> +

+ +
+ + t('Loading configuration…')); ?> +
+ + + + + +