334 lines
11 KiB
PHP
334 lines
11 KiB
PHP
<?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 => '',
|
|
};
|
|
}
|
|
}
|