*/ 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 */ 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> 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 */ 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 */ 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 => '', }; } }