mail_calendar_sync/lib/Service/MailService.php
2026-02-09 23:02:34 -05:00

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