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