initial commit

This commit is contained in:
Thomas Faour 2026-02-09 23:02:34 -05:00
commit 560686040a
24 changed files with 2864 additions and 0 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.

169
README.md Normal file
View File

@ -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

27
appinfo/info.xml Normal file
View File

@ -0,0 +1,27 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>mail_calendar_sync</id>
<name>Mail Calendar Sync</name>
<summary>Automatically apply calendar invitation responses from Mail</summary>
<description><![CDATA[Connects your Nextcloud Mail to your Calendar so that iMIP responses (accept, decline, tentative) received via email are automatically applied to matching events.]]></description>
<version>1.1.0</version>
<licence>agpl</licence>
<author mail="dev@example.com">Mail Calendar Sync Contributors</author>
<namespace>MailCalendarSync</namespace>
<category>groupware</category>
<category>integration</category>
<dependencies>
<nextcloud min-version="28" max-version="32"/>
</dependencies>
<settings>
<personal>OCA\MailCalendarSync\Settings\PersonalSettings</personal>
<personal-section>OCA\MailCalendarSync\Settings\PersonalSection</personal-section>
</settings>
<background-jobs>
<job>OCA\MailCalendarSync\BackgroundJob\ProcessImipResponsesJob</job>
</background-jobs>
</info>

14
appinfo/routes.php Normal file
View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'routes' => [
['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'],
],
];

10
composer.json Normal file
View File

@ -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/"
}
}
}

220
css/personal-settings.css Normal file
View File

@ -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);
}

295
js/personal-settings.js Normal file
View File

@ -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 = '<option value="">Select a mail account…</option>';
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 = '<option value="">Select a calendar…</option>';
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 = `
<td class="mcs-log-date">${escapeHtml(date)}</td>
<td class="mcs-log-event">${escapeHtml(entry.eventSummary || entry.eventUid || '—')}</td>
<td class="mcs-log-action"><span class="mcs-badge mcs-badge-${entry.action.toLowerCase()}">${escapeHtml(entry.action)}</span></td>
<td class="mcs-log-message">${escapeHtml(entry.message || '—')}</td>
`;
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);
})();

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\AppInfo;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'mail_calendar_sync';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// Nothing to register at this time - background job is declared in info.xml
}
public function boot(IBootContext $context): void {
// Nothing to boot
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\BackgroundJob;
use OCA\MailCalendarSync\Db\ConfigMapper;
use OCA\MailCalendarSync\Db\LogMapper;
use OCA\MailCalendarSync\Db\ProcessedMessageMapper;
use OCA\MailCalendarSync\Service\SyncService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
/**
* Background job that scans for new iMIP calendar responses
* in connected mail accounts and applies them to calendars.
*
* The job itself runs every 5 minutes (the minimum), but
* per-user sync intervals are respected via last-sync tracking.
*/
class ProcessImipResponsesJob extends TimedJob {
public function __construct(
ITimeFactory $time,
private SyncService $syncService,
private ConfigMapper $configMapper,
private LogMapper $logMapper,
private ProcessedMessageMapper $processedMapper,
private LoggerInterface $logger,
) {
parent::__construct($time);
// Job checks every 5 minutes; per-user interval handled in run()
$this->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,
]);
}
}
}

View File

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Controller;
use OCA\MailCalendarSync\AppInfo\Application;
use OCA\MailCalendarSync\Db\Config;
use OCA\MailCalendarSync\Db\ConfigMapper;
use OCA\MailCalendarSync\Db\LogMapper;
use OCA\MailCalendarSync\Service\CalendarService;
use OCA\MailCalendarSync\Service\MailService;
use OCA\MailCalendarSync\Service\SyncService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
class SettingsController extends ApiController {
private ConfigMapper $configMapper;
private LogMapper $logMapper;
private CalendarService $calendarService;
private MailService $mailService;
private SyncService $syncService;
private ?string $userId;
public function __construct(
IRequest $request,
ConfigMapper $configMapper,
LogMapper $logMapper,
CalendarService $calendarService,
MailService $mailService,
SyncService $syncService,
?string $userId,
) {
parent::__construct(
Application::APP_ID,
$request,
'PUT, GET, POST',
'Authorization, Content-Type, Accept, requesttoken',
86400
);
$this->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
);
}
}
}

64
lib/Db/Config.php Normal file
View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getUserId()
* @method void setUserId(string $userId)
* @method int getEnabled()
* @method void setEnabled(int $enabled)
* @method int|null getMailAccountId()
* @method void setMailAccountId(?int $mailAccountId)
* @method string|null getCalendarUri()
* @method void setCalendarUri(?string $calendarUri)
* @method int getAutoAccept()
* @method void setAutoAccept(int $autoAccept)
* @method int getSyncInterval()
* @method void setSyncInterval(int $syncInterval)
* @method string|null getCreatedAt()
* @method void setCreatedAt(?string $createdAt)
* @method string|null getUpdatedAt()
* @method void setUpdatedAt(?string $updatedAt)
*/
class Config extends Entity {
protected string $userId = '';
protected int $enabled = 0;
protected ?int $mailAccountId = null;
protected ?string $calendarUri = null;
protected int $autoAccept = 0;
protected int $syncInterval = 600;
protected ?string $createdAt = null;
protected ?string $updatedAt = null;
public function __construct() {
$this->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(),
];
}
}

44
lib/Db/ConfigMapper.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @extends QBMapper<Config>
*/
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);
}
}

50
lib/Db/LogEntry.php Normal file
View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getUserId()
* @method void setUserId(string $userId)
* @method string getEventUid()
* @method void setEventUid(string $eventUid)
* @method string|null getEventSummary()
* @method void setEventSummary(?string $eventSummary)
* @method string getAction()
* @method void setAction(string $action)
* @method string|null getAttendeeEmail()
* @method void setAttendeeEmail(?string $attendeeEmail)
* @method string|null getFromEmail()
* @method void setFromEmail(?string $fromEmail)
* @method string|null getMessage()
* @method void setMessage(?string $message)
* @method string|null getCreatedAt()
* @method void setCreatedAt(?string $createdAt)
*/
class LogEntry extends Entity {
protected string $userId = '';
protected string $eventUid = '';
protected ?string $eventSummary = null;
protected string $action = '';
protected ?string $attendeeEmail = null;
protected ?string $fromEmail = null;
protected ?string $message = null;
protected ?string $createdAt = null;
public function toArray(): array {
return [
'id' => $this->getId(),
'eventUid' => $this->getEventUid(),
'eventSummary' => $this->getEventSummary(),
'action' => $this->getAction(),
'attendeeEmail' => $this->getAttendeeEmail(),
'fromEmail' => $this->getFromEmail(),
'message' => $this->getMessage(),
'createdAt' => $this->getCreatedAt(),
];
}
}

73
lib/Db/LogMapper.php Normal file
View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/**
* @extends QBMapper<LogEntry>
*/
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();
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getUserId()
* @method void setUserId(string $userId)
* @method int getMailAccountId()
* @method void setMailAccountId(int $mailAccountId)
* @method string getMessageId()
* @method void setMessageId(string $messageId)
* @method string|null getProcessedAt()
* @method void setProcessedAt(?string $processedAt)
*/
class ProcessedMessage extends Entity {
protected string $userId = '';
protected int $mailAccountId = 0;
protected string $messageId = '';
protected ?string $processedAt = null;
public function __construct() {
$this->addType('mailAccountId', 'integer');
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/**
* @extends QBMapper<ProcessedMessage>
*/
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();
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version1000Date20250209000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// User configuration table
if (!$schema->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;
}
}

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Fresh migration that creates tables if they don't exist.
* Safe to run even if the original migration partially succeeded.
*/
class Version1003Date20250210000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->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;
}
}

View File

@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Service;
use OCA\MailCalendarSync\Db\LogMapper;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager as ICalendarManager;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
class CalendarService {
public function __construct(
private ICalendarManager $calendarManager,
private LogMapper $logMapper,
private LoggerInterface $logger,
) {
}
/**
* Get all writable calendars for a user.
*
* @return array<array{uri: string, name: string, color: string|null}>
*/
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;
}
}

333
lib/Service/MailService.php Normal file
View File

@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Service;
use OCA\MailCalendarSync\Db\ProcessedMessageMapper;
use Psr\Log\LoggerInterface;
use OCP\IDBConnection;
use Sabre\VObject\Reader;
/**
* Service that connects to the Nextcloud Mail app's database and IMAP
* to find and extract iCalendar (ICS) attachments from incoming mail.
*
* NOTE: Emails are NEVER deleted or modified. We only read them.
*/
class MailService {
public function __construct(
private IDBConnection $db,
private ProcessedMessageMapper $processedMapper,
private LoggerInterface $logger,
) {
}
/**
* Get mail accounts for a user from the Mail app's database.
*
* @return array<array{id: int, name: string, email: string}>
*/
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<array{messageId: string, subject: string, uid: int, mailboxId: int}>
*/
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<array{ics: string, method: string, from: string}> 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 => '',
};
}
}

211
lib/Service/SyncService.php Normal file
View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Service;
use OCA\MailCalendarSync\Db\Config;
use OCA\MailCalendarSync\Db\ConfigMapper;
use OCA\MailCalendarSync\Db\LogMapper;
use OCA\MailCalendarSync\Db\ProcessedMessageMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Reader;
/**
* Orchestrates the full mail calendar sync process for a user.
*
* NOTE: This service is READ-ONLY on email. It never deletes or modifies
* any email messages. It only reads ICS attachments and applies them
* to the calendar.
*/
class SyncService {
public function __construct(
private ConfigMapper $configMapper,
private MailService $mailService,
private CalendarService $calendarService,
private ProcessedMessageMapper $processedMapper,
private LogMapper $logMapper,
private LoggerInterface $logger,
) {
}
/**
* Run sync for a specific user.
*
* @return array{processed: int, updated: int, errors: int, messages: string[]}
*/
public function syncForUser(string $userId): array {
$stats = ['processed' => 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;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class PersonalSection implements IIconSection {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
) {
}
public function getID(): string {
return 'mail_calendar_sync';
}
public function getName(): string {
return $this->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 '';
}
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace OCA\MailCalendarSync\Settings;
use OCA\MailCalendarSync\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings;
class PersonalSettings implements ISettings {
public function getForm(): TemplateResponse {
return new TemplateResponse(Application::APP_ID, 'personal-settings');
}
public function getSection(): string {
return 'mail_calendar_sync';
}
public function getPriority(): int {
return 80;
}
}

View File

@ -0,0 +1,115 @@
<?php
\OCP\Util::addScript('mail_calendar_sync', 'personal-settings');
\OCP\Util::addStyle('mail_calendar_sync', 'personal-settings');
?>
<div id="mail-calendar-sync-settings" class="section">
<h2>
<?php p($l->t('Mail Calendar Sync')); ?>
</h2>
<p class="settings-hint">
<?php p($l->t('Automatically apply calendar invitation responses (accept, decline, tentative) received via email to your calendar events. Emails are never deleted or modified.')); ?>
</p>
<div id="mcs-loading" class="mcs-loading">
<span class="icon-loading-small"></span>
<?php p($l->t('Loading configuration…')); ?>
</div>
<div id="mcs-config" style="display:none;">
<!-- Enable toggle -->
<div class="mcs-field">
<input type="checkbox" id="mcs-enabled" class="checkbox">
<label for="mcs-enabled"><?php p($l->t('Enable automatic calendar sync from mail')); ?></label>
</div>
<!-- Mail account selection -->
<div class="mcs-field" id="mcs-mail-section" style="display:none;">
<label for="mcs-mail-account"><?php p($l->t('Mail account')); ?></label>
<select id="mcs-mail-account">
<option value=""><?php p($l->t('Select a mail account…')); ?></option>
</select>
<p class="mcs-field-hint">
<?php p($l->t('Choose which mail account to scan for calendar invitation responses.')); ?>
</p>
</div>
<!-- Calendar selection -->
<div class="mcs-field" id="mcs-calendar-section" style="display:none;">
<label for="mcs-calendar"><?php p($l->t('Target calendar')); ?></label>
<select id="mcs-calendar">
<option value=""><?php p($l->t('Select a calendar…')); ?></option>
</select>
<p class="mcs-field-hint">
<?php p($l->t('Choose which calendar to apply invitation responses to.')); ?>
</p>
</div>
<!-- Sync interval -->
<div class="mcs-field" id="mcs-interval-section" style="display:none;">
<label for="mcs-sync-interval"><?php p($l->t('Sync frequency')); ?></label>
<select id="mcs-sync-interval">
<option value="300"><?php p($l->t('Every 5 minutes')); ?></option>
<option value="600"><?php p($l->t('Every 10 minutes')); ?></option>
<option value="900"><?php p($l->t('Every 15 minutes')); ?></option>
<option value="1800"><?php p($l->t('Every 30 minutes')); ?></option>
<option value="3600"><?php p($l->t('Every hour')); ?></option>
<option value="7200"><?php p($l->t('Every 2 hours')); ?></option>
<option value="14400"><?php p($l->t('Every 4 hours')); ?></option>
<option value="43200"><?php p($l->t('Every 12 hours')); ?></option>
<option value="86400"><?php p($l->t('Once a day')); ?></option>
</select>
<p class="mcs-field-hint">
<?php p($l->t('How often to check for new calendar invitation responses.')); ?>
</p>
</div>
<!-- Auto-accept toggle -->
<div class="mcs-field" id="mcs-auto-accept-section" style="display:none;">
<input type="checkbox" id="mcs-auto-accept" class="checkbox">
<label for="mcs-auto-accept"><?php p($l->t('Automatically add new invitations to calendar')); ?></label>
<p class="mcs-field-hint">
<?php p($l->t('When enabled, new event invitations (REQUEST) will be automatically added to your calendar. When disabled, only responses to existing events will be processed.')); ?>
</p>
</div>
<!-- Save button -->
<div class="mcs-field" id="mcs-save-section" style="display:none;">
<button id="mcs-save" class="primary">
<?php p($l->t('Save')); ?>
</button>
<button id="mcs-trigger-sync" class="secondary">
<?php p($l->t('Sync now')); ?>
</button>
<span id="mcs-status" class="mcs-status"></span>
</div>
<!-- Sync details (shown after manual sync) -->
<div id="mcs-sync-details" style="display:none;">
<h3><?php p($l->t('Last sync details')); ?></h3>
<pre id="mcs-sync-log" class="mcs-sync-log"></pre>
</div>
</div>
<!-- Activity log -->
<div id="mcs-log-section" style="display:none;">
<h3><?php p($l->t('Recent activity')); ?></h3>
<table id="mcs-log-table" class="mcs-log-table">
<thead>
<tr>
<th><?php p($l->t('Date')); ?></th>
<th><?php p($l->t('Event')); ?></th>
<th><?php p($l->t('Action')); ?></th>
<th><?php p($l->t('Details')); ?></th>
</tr>
</thead>
<tbody id="mcs-log-body">
</tbody>
</table>
<p id="mcs-log-empty" style="display:none;">
<?php p($l->t('No recent activity.')); ?>
</p>
</div>
</div>