initial commit
This commit is contained in:
commit
560686040a
14
LICENSES/AGPL-3.0-or-later.txt
Normal file
14
LICENSES/AGPL-3.0-or-later.txt
Normal 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
169
README.md
Normal 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
27
appinfo/info.xml
Normal 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
14
appinfo/routes.php
Normal 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
10
composer.json
Normal 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
220
css/personal-settings.css
Normal 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
295
js/personal-settings.js
Normal 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);
|
||||
})();
|
||||
27
lib/AppInfo/Application.php
Normal file
27
lib/AppInfo/Application.php
Normal 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
|
||||
}
|
||||
}
|
||||
94
lib/BackgroundJob/ProcessImipResponsesJob.php
Normal file
94
lib/BackgroundJob/ProcessImipResponsesJob.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
215
lib/Controller/SettingsController.php
Normal file
215
lib/Controller/SettingsController.php
Normal 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
64
lib/Db/Config.php
Normal 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
44
lib/Db/ConfigMapper.php
Normal 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
50
lib/Db/LogEntry.php
Normal 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
73
lib/Db/LogMapper.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
lib/Db/ProcessedMessage.php
Normal file
29
lib/Db/ProcessedMessage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
62
lib/Db/ProcessedMessageMapper.php
Normal file
62
lib/Db/ProcessedMessageMapper.php
Normal 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();
|
||||
}
|
||||
}
|
||||
143
lib/Migration/Version1000Date20250209000000.php
Normal file
143
lib/Migration/Version1000Date20250209000000.php
Normal 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;
|
||||
}
|
||||
}
|
||||
147
lib/Migration/Version1003Date20250210000000.php
Normal file
147
lib/Migration/Version1003Date20250210000000.php
Normal 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;
|
||||
}
|
||||
}
|
||||
446
lib/Service/CalendarService.php
Normal file
446
lib/Service/CalendarService.php
Normal 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
333
lib/Service/MailService.php
Normal 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
211
lib/Service/SyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
lib/Settings/PersonalSection.php
Normal file
38
lib/Settings/PersonalSection.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
24
lib/Settings/PersonalSettings.php
Normal file
24
lib/Settings/PersonalSettings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
115
templates/personal-settings.php
Normal file
115
templates/personal-settings.php
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user