Thomas Faour a9023d29d9 Working
2026-02-10 23:31:24 -05:00

387 lines
13 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.
* Requires the Nextcloud Mail app to be installed (provides Horde libraries).
*/
class MailService {
public function __construct(
private IDBConnection $db,
private ProcessedMessageMapper $processedMapper,
private LoggerInterface $logger,
) {
}
/**
* Check whether the Horde IMAP libraries are available (provided by Mail app).
*/
public function isImapAvailable(): bool {
return class_exists(\Horde_Imap_Client_Socket::class);
}
/**
* 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.
*
* @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(
1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT
)))
->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 ICS data from multiple messages using a SINGLE IMAP connection.
*
* This is much more efficient than connecting per-message.
* NOTE: This is READ-ONLY. Emails are never deleted or modified.
*
* @param array $messages Array of message arrays from findRecentImipMessages
* @return array<int, array<array{ics: string, method: string, from: string}>> Keyed by message index
*/
public function fetchIcsFromMessages(int $mailAccountId, string $userId, array $messages): array {
if (empty($messages)) {
return [];
}
if (!$this->isImapAvailable()) {
throw new \RuntimeException(
'Horde IMAP libraries not available. Is the Nextcloud Mail app installed?'
);
}
$accountDetails = $this->getMailAccountDetails($mailAccountId, $userId);
if ($accountDetails === null) {
throw new \RuntimeException("Mail account {$mailAccountId} not found for user {$userId}");
}
$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);
}
$client = new \Horde_Imap_Client_Socket([
'username' => $imapUser,
'password' => $password,
'hostspec' => $imapHost,
'port' => $imapPort,
'secure' => $imapSsl,
]);
$allResults = [];
try {
// Group messages by mailbox for efficient fetching
$byMailbox = [];
foreach ($messages as $idx => $message) {
$mailboxId = $message['mailboxId'];
if (!isset($byMailbox[$mailboxId])) {
$byMailbox[$mailboxId] = [];
}
$byMailbox[$mailboxId][$idx] = $message;
}
foreach ($byMailbox as $mailboxId => $mailboxMessages) {
$mailboxName = $this->getMailboxName($mailboxId);
if ($mailboxName === null) {
$this->logger->warning("Mailbox ID {$mailboxId} not found in database");
continue;
}
$mailbox = new \Horde_Imap_Client_Mailbox($mailboxName);
// Fetch all messages in this mailbox at once
$uids = array_map(fn($m) => $m['uid'], $mailboxMessages);
$ids = new \Horde_Imap_Client_Ids($uids);
$query = new \Horde_Imap_Client_Fetch_Query();
$query->structure();
$query->envelope();
$query->fullText(['peek' => true]);
$fetchResults = $client->fetch($mailbox, $query, ['ids' => $ids]);
// Map results back to message indices
foreach ($mailboxMessages as $idx => $message) {
$uid = $message['uid'];
if (!isset($fetchResults[$uid])) {
continue;
}
$fetchData = $fetchResults[$uid];
$icsResults = $this->extractIcsFromFetchData($fetchData);
if (!empty($icsResults)) {
$allResults[$idx] = $icsResults;
}
}
}
} finally {
try {
$client->logout();
} catch (\Throwable $e) {
// ignore logout errors
}
}
return $allResults;
}
/**
* Fetch ICS data from a single message via IMAP.
* Convenience wrapper around fetchIcsFromMessages for single-message use.
*
* @return array<array{ics: string, method: string, from: string}>
*/
public function fetchIcsFromMessage(int $mailAccountId, string $userId, int $mailboxId, int $messageUid): array {
$messages = [[
'uid' => $messageUid,
'mailboxId' => $mailboxId,
'messageId' => '',
'subject' => '',
]];
$results = $this->fetchIcsFromMessages($mailAccountId, $userId, $messages);
return $results[0] ?? [];
}
/**
* Extract ICS data from a Horde fetch result.
*
* @return array<array{ics: string, method: string, from: string}>
*/
private function extractIcsFromFetchData($fetchData): array {
$envelope = $fetchData->getEnvelope();
$fromAddresses = $envelope->from ?? [];
$from = '';
if (count($fromAddresses) > 0) {
$firstFrom = $fromAddresses[0];
$from = ($firstFrom->bare_address ?? '');
}
$fullText = $fetchData->getFullMsg();
if ($fullText === null) {
return [];
}
$mimeMessage = \Horde_Mime_Part::parseMessage($fullText);
$icsParts = $this->findCalendarParts($mimeMessage);
$results = [];
foreach ($icsParts as $icsPart) {
$icsData = $icsPart->getContents();
if (empty($icsData)) {
continue;
}
$method = 'REQUEST';
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,
]);
}
$results[] = [
'ics' => $icsData,
'method' => $method,
'from' => $from,
];
}
return $results;
}
/**
* 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 => '',
};
}
}